Repository: CDE90/web-spotify-thing
Pull request #17: Add comparisons to previous period
Author: login: CDE90
Created: 2025-03-04T19:15:34Z | Merged: 2025-03-04T21:17:37Z

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Enhanced dashboard visuals with comparisons of current and previous data, including playtime trends, top artists/tracks/albums, and total metrics.
  - Introduced customizable line styles for charts.
  - Added interactive tooltip and badge components for improved UI experience.
  - Implemented ordinal formatting for more natural number displays.
  - Introduced a `PercentageBadge` component to visually represent percentage changes.
  - Enhanced leaderboard functionality to allow comparative analysis over different timeframes.
  - Added tooltip functionality to display full timestamps for recent listens.
  - Introduced a new `Badge` component for variant styling.
  
- **Refactor**
  - Simplified music data retrieval by removing obsolete token management, streamlining the process for displaying currently playing tracks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/package.json b/package.json
index 0317985..5744997 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
         "@radix-ui/react-select": "^2.1.5",
         "@radix-ui/react-slot": "^1.1.1",
         "@radix-ui/react-tabs": "^1.1.3",
+        "@radix-ui/react-tooltip": "^1.1.8",
         "@react-aria/datepicker": "^3.13.0",
         "@react-stately/datepicker": "^3.12.0",
         "@remixicon/react": "^4.6.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ab48cec..fd48c47 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
       '@radix-ui/react-tabs':
         specifier: ^1.1.3
         version: 1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-tooltip':
+        specifier: ^1.1.8
+        version: 1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       '@react-aria/datepicker':
         specifier: ^3.13.0
         version: 3.13.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -828,6 +831,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-arrow@1.1.2':
+    resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-avatar@1.1.2':
     resolution: {integrity: sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==}
     peerDependencies:
@@ -920,6 +936,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-dismissable-layer@1.1.5':
+    resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-dropdown-menu@2.1.5':
     resolution: {integrity: sha512-50ZmEFL1kOuLalPKHrLWvPFMons2fGx9TqQCWlPwDVpbAnaUJ1g4XNcKqFNMQymYU0kKWR4MDDi+9vUQBGFgcQ==}
     peerDependencies:
@@ -1029,6 +1058,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-popper@1.2.2':
+    resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-portal@1.1.3':
     resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==}
     peerDependencies:
@@ -1042,6 +1084,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-portal@1.1.4':
+    resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-presence@1.1.2':
     resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==}
     peerDependencies:
@@ -1164,6 +1219,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-tooltip@1.1.8':
+    resolution: {integrity: sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-use-callback-ref@1.1.0':
     resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
     peerDependencies:
@@ -1240,6 +1308,19 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@radix-ui/react-visually-hidden@1.1.2':
+    resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/rect@1.1.0':
     resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
 
@@ -3886,6 +3967,15 @@ snapshots:
       '@types/react': 19.0.8
       '@types/react-dom': 19.0.3(@types/react@19.0.8)
 
+  '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.8
+      '@types/react-dom': 19.0.3(@types/react@19.0.8)
+
   '@radix-ui/react-avatar@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
@@ -3969,6 +4059,19 @@ snapshots:
       '@types/react': 19.0.8
       '@types/react-dom': 19.0.3(@types/react@19.0.8)
 
+  '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.1
+      '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.8
+      '@types/react-dom': 19.0.3(@types/react@19.0.8)
+
   '@radix-ui/react-dropdown-menu@2.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@radix-ui/primitive': 1.1.1
@@ -4106,6 +4209,24 @@ snapshots:
       '@types/react': 19.0.8
       '@types/react-dom': 19.0.3(@types/react@19.0.8)
 
+  '@radix-ui/react-popper@1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-arrow': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-use-rect': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-use-size': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/rect': 1.1.0
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.8
+      '@types/react-dom': 19.0.3(@types/react@19.0.8)
+
   '@radix-ui/react-portal@1.1.3(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@radix-ui/react-primitive': 2.0.1(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -4116,6 +4237,16 @@ snapshots:
       '@types/react': 19.0.8
       '@types/react-dom': 19.0.3(@types/react@19.0.8)
 
+  '@radix-ui/react-portal@1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.8
+      '@types/react-dom': 19.0.3(@types/react@19.0.8)
+
   '@radix-ui/react-presence@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
@@ -4247,6 +4378,26 @@ snapshots:
       '@types/react': 19.0.8
       '@types/react-dom': 19.0.3(@types/react@19.0.8)
 
+  '@radix-ui/react-tooltip@1.1.8(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.1
+      '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-context': 1.1.1(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-id': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@radix-ui/react-slot': 1.1.2(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.8)(react@19.0.0)
+      '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.8
+      '@types/react-dom': 19.0.3(@types/react@19.0.8)
+
   '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.8)(react@19.0.0)':
     dependencies:
       react: 19.0.0
@@ -4302,6 +4453,15 @@ snapshots:
       '@types/react': 19.0.8
       '@types/react-dom': 19.0.3(@types/react@19.0.8)
 
+  '@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+    optionalDependencies:
+      '@types/react': 19.0.8
+      '@types/react-dom': 19.0.3(@types/react@19.0.8)
+
   '@radix-ui/rect@1.1.0': {}
 
   '@react-aria/datepicker@3.13.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
diff --git a/src/app/api/update-now-playing/route.ts b/src/app/api/update-now-playing/route.ts
index 8a8363b..36a9a61 100644
--- a/src/app/api/update-now-playing/route.ts
+++ b/src/app/api/update-now-playing/route.ts
@@ -4,10 +4,8 @@
 import { env } from "@/env";
 import { db } from "@/server/db";
 import * as schema from "@/server/db/schema";
-import { getSpotifyToken } from "@/server/lib";
-import { getCurrentlyPlaying } from "@/server/spotify/spotify";
+import { getUserPlaying } from "@/server/spotify/spotify";
 import type { Image } from "@/server/spotify/types";
-import { clerkClient } from "@clerk/nextjs/server";
 import type { InferInsertModel } from "drizzle-orm";
 import { and, desc, eq } from "drizzle-orm";
 import { NextResponse } from "next/server";
@@ -58,9 +56,6 @@ export async function POST(request: Request) {
         }
     }
 
-    // Get Clerk API client
-    const apiClient = await clerkClient();
-
     // Get all enabled users
     const filters = [eq(schema.users.enabled, true)];
 
@@ -74,27 +69,9 @@ export async function POST(request: Request) {
         .where(and(...filters));
 
     for (const user of users) {
-        let spotifyToken;
-        try {
-            // Get the user's Spotify access token
-            spotifyToken = await getSpotifyToken(apiClient, user.id);
-
-            if (!spotifyToken) {
-                continue;
-            }
-
-            // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        } catch (e: any) {
-            console.log(
-                `Error getting Spotify token for user ${user.id} (${spotifyToken ?? "NA"}): ${e}`,
-            );
-            continue;
-        }
-
         let currentlyPlaying;
         try {
-            // Get the currently playing song
-            currentlyPlaying = await getCurrentlyPlaying(spotifyToken);
+            currentlyPlaying = await getUserPlaying(user.id);
 
             if (!currentlyPlaying?.is_playing) {
                 continue;
@@ -103,7 +80,7 @@ export async function POST(request: Request) {
             // eslint-disable-next-line @typescript-eslint/no-explicit-any
         } catch (e: any) {
             console.log(
-                `Error getting currently playing for user ${user.id} (${spotifyToken}): ${e}`,
+                `Error getting currently playing for user ${user.id}: ${e}`,
             );
             continue;
         }
diff --git a/src/app/dashboard/_components/daily-playtime-graph.tsx b/src/app/dashboard/_components/daily-playtime-graph.tsx
index 0e30a29..892481e 100644
--- a/src/app/dashboard/_components/daily-playtime-graph.tsx
+++ b/src/app/dashboard/_components/daily-playtime-graph.tsx
@@ -4,11 +4,11 @@ import {
     PlaytimeChart,
     type DailyPlaytime,
 } from "@/components/ui-parts/PlaytimeChart";
+import { dateFormatter } from "@/lib/utils";
 import { db } from "@/server/db";
 import { listeningHistory } from "@/server/db/schema";
-import { getTimeFilters } from "@/server/lib";
+import { getPrevDateRange, getTimeFilters } from "@/server/lib";
 import { and, asc, eq, gte, sql } from "drizzle-orm";
-import { dateFormatter } from "@/lib/utils";
 
 function isSameDay(date1: Date, date2: Date) {
     return (
@@ -30,15 +30,18 @@ export async function DailyPlaytimeGraph({
     "use cache";
 
     const timeFilters = getTimeFilters({ from: startDate, to: endDate });
+    const prevDateRange = getPrevDateRange({ from: startDate, to: endDate });
+    const prevTimeFilters = getTimeFilters(prevDateRange);
 
     // Determine if we're looking at a single day
     const isSingleDayView = isSameDay(startDate, endDate);
 
     let dailyPlaytime: DailyPlaytime[];
+    let prevDailyPlaytime: DailyPlaytime[];
 
     // Get the playtime data based on whether we're viewing a single day or multiple days
     if (isSingleDayView) {
-        // Hourly breakdown for single day
+        // Hourly breakdown for single day - Current Period
         const aggPlaytimeSq = db.$with("agg_playtime").as(
             db
                 .select({
@@ -65,7 +68,6 @@ export async function DailyPlaytimeGraph({
         const hoursSq = db.$with("hours").as(
             db
                 .select({
-                    // hour: sql<number>`generate_series(0, 23)`.as("hour"),
                     hour: sql<number>`hh::int`.as("hour"),
                 })
                 .from(sql`generate_series(0, 23) as hh`),
@@ -84,8 +86,49 @@ export async function DailyPlaytimeGraph({
             .from(hoursSq)
             .leftJoin(aggPlaytimeSq, eq(hoursSq.hour, aggPlaytimeSq.playedAt))
             .orderBy(asc(hoursSq.hour));
+
+        // Previous Period Hourly Breakdown
+        const prevAggPlaytimeSq = db.$with("prev_agg_playtime").as(
+            db
+                .select({
+                    playedAt:
+                        sql<number>`date_part('hour', ${listeningHistory.playedAt})::int`.as(
+                            "playedAt",
+                        ),
+                    playtime:
+                        sql<number>`sum(${listeningHistory.progressMs}) / 1000`.as(
+                            "playtime",
+                        ),
+                })
+                .from(listeningHistory)
+                .where(
+                    and(
+                        prevTimeFilters,
+                        gte(listeningHistory.progressMs, 30 * 1000),
+                        eq(listeningHistory.userId, userId),
+                    ),
+                )
+                .groupBy(sql`date_part('hour', ${listeningHistory.playedAt})`),
+        );
+
+        prevDailyPlaytime = await db
+            .with(hoursSq, prevAggPlaytimeSq)
+            .select({
+                date: sql<string>`${sql.raw(`'${prevDateRange.from.toLocaleDateString()}'`)} || ' ' || 
+                       case when ${hoursSq.hour} < 10 
+                            then '0' || ${hoursSq.hour}::text 
+                            else ${hoursSq.hour}::text 
+                       end || ':00'`,
+                playtime: sql<number>`coalesce(${prevAggPlaytimeSq.playtime}, 0)::int`,
+            })
+            .from(hoursSq)
+            .leftJoin(
+                prevAggPlaytimeSq,
+                eq(hoursSq.hour, prevAggPlaytimeSq.playedAt),
+            )
+            .orderBy(asc(hoursSq.hour));
     } else {
-        // Daily breakdown for multiple days
+        // Daily breakdown for multiple days - Current Period
         const aggPlaytimeSq = db.$with("agg_playtime").as(
             db
                 .select({
@@ -127,7 +170,58 @@ export async function DailyPlaytimeGraph({
             })
             .from(datesSq)
             .leftJoin(aggPlaytimeSq, eq(datesSq.date, aggPlaytimeSq.playedAt));
+
+        // Previous Period Daily Breakdown
+        const prevAggPlaytimeSq = db.$with("prev_agg_playtime").as(
+            db
+                .select({
+                    playedAt:
+                        sql<string>`${listeningHistory.playedAt}::date`.as(
+                            "playedAt",
+                        ),
+                    playtime:
+                        sql<number>`sum(${listeningHistory.progressMs}) / 1000`.as(
+                            "playtime",
+                        ),
+                })
+                .from(listeningHistory)
+                .where(
+                    and(
+                        prevTimeFilters,
+                        gte(listeningHistory.progressMs, 30 * 1000),
+                        eq(listeningHistory.userId, userId),
+                    ),
+                )
+                .groupBy(sql`${listeningHistory.playedAt}::date`),
+        );
+
+        const prevDatesSq = db.$with("prev_dates").as(
+            db
+                .select({
+                    date: sql<string>`dd::date`.as("date"),
+                })
+                .from(
+                    sql`generate_series(${dateFormatter(prevDateRange.from)}::timestamp, ${dateFormatter(prevDateRange.to)}::timestamp, '1 day'::interval) as dd`,
+                ),
+        );
+
+        prevDailyPlaytime = await db
+            .with(prevDatesSq, prevAggPlaytimeSq)
+            .select({
+                date: prevDatesSq.date,
+                playtime: sql<number>`coalesce(${prevAggPlaytimeSq.playtime}, 0)::int`,
+            })
+            .from(prevDatesSq)
+            .leftJoin(
+                prevAggPlaytimeSq,
+                eq(prevDatesSq.date, prevAggPlaytimeSq.playedAt),
+            );
     }
 
-    return <PlaytimeChart dailyPlaytime={dailyPlaytime} />;
+    return (
+        <PlaytimeChart
+            dailyPlaytime={dailyPlaytime}
+            prevDailyPlaytime={prevDailyPlaytime}
+        />
+    );
 }
diff --git a/src/app/dashboard/_components/top-tables.tsx b/src/app/dashboard/_components/top-tables.tsx
index bd71a36..5a37b82 100644
--- a/src/app/dashboard/_components/top-tables.tsx
+++ b/src/app/dashboard/_components/top-tables.tsx
@@ -1,5 +1,6 @@
 import "server-only";
 
+import { PercentageBadge } from "@/components/percentage-badge";
 import { Skeleton } from "@/components/ui/skeleton";
 import {
     Table,
@@ -10,6 +11,12 @@ import {
     TableHeadRow,
     TableRow,
 } from "@/components/ui/table";
+import {
+    Tooltip,
+    TooltipContent,
+    TooltipProvider,
+    TooltipTrigger,
+} from "@/components/ui/tooltip";
 import { db } from "@/server/db";
 import {
     albums,
@@ -18,8 +25,15 @@ import {
     listeningHistory,
     tracks,
 } from "@/server/db/schema";
-import { type DateRange, getTimeFilters } from "@/server/lib";
-import { and, asc, desc, eq, gte, sql } from "drizzle-orm";
+import {
+    calculateComparisons,
+    type DateRange,
+    getPrevDateRange,
+    getRankChangeTooltip,
+    getTimeFilters,
+} from "@/server/lib";
+import { and, asc, count, desc, eq, gte, sum } from "drizzle-orm";
+import { ArrowDown, ArrowUp, Minus } from "lucide-react";
 import Image from "next/image";
 import Link from "next/link";
 
@@ -73,10 +87,13 @@ export async function TopArtists({
     "use cache";
 
     const timeFilters = getTimeFilters(dateRange);
+    const prevDateRange = getPrevDateRange(dateRange);
+    const prevTimeFilters = getTimeFilters(prevDateRange);
 
+    // Current period top artists
     const topArtists = await db
         .select({
-            count: sql<number>`count(*)`,
+            count: count(),
             artist: artists.name,
             imageUrl: artists.imageUrl,
             artistId: artists.id,
@@ -95,12 +112,47 @@ export async function TopArtists({
         )
         .groupBy(artists.id)
         .orderBy(
-            desc(sql`count(*)`),
-            desc(sql`sum(${listeningHistory.progressMs})`),
+            desc(count()),
+            desc(sum(listeningHistory.progressMs)),
+            asc(artists.name),
+        )
+        .limit(limit);
+
+    // Previous period top artists
+    const prevTopArtists = await db
+        .select({
+            count: count(),
+            artist: artists.name,
+            artistId: artists.id,
+        })
+        .from(listeningHistory)
+        .innerJoin(tracks, eq(listeningHistory.trackId, tracks.id))
+        .innerJoin(artistTracks, eq(tracks.id, artistTracks.trackId))
+        .innerJoin(artists, eq(artistTracks.artistId, artists.id))
+        .where(
+            and(
+                prevTimeFilters,
+                eq(listeningHistory.userId, userId),
+                gte(listeningHistory.progressMs, 30 * 1000),
+                eq(artistTracks.isPrimaryArtist, true),
+            ),
+        )
+        .groupBy(artists.id)
+        .orderBy(
+            desc(count()),
+            desc(sum(listeningHistory.progressMs)),
             asc(artists.name),
         )
         .limit(limit);
 
+    // Calculate rank and count changes
+    const artistComparisons = calculateComparisons(
+        topArtists,
+        prevTopArtists,
+        "artistId",
+        "count",
+    );
+
     return (
         <Table>
             <TableHeader>
@@ -111,9 +163,34 @@ export async function TopArtists({
                 </TableHeadRow>
             </TableHeader>
             <TableBody>
-                {topArtists.map((artist, index) => (
+                {artistComparisons.map((artist, index) => (
                     <TableRow key={artist.artistId}>
-                        <TableCell>{index + 1}</TableCell>
+                        <TableCell>
+                            <div className="flex items-center gap-2">
+                                {index + 1}
+                                {artist.rankChange !== null && (
+                                    <TooltipProvider>
+                                        <Tooltip>
+                                            <TooltipTrigger>
+                                                {artist.rankChange > 0 ? (
+                                                    <ArrowUp className="h-4 w-4 text-green-600" />
+                                                ) : artist.rankChange < 0 ? (
+                                                    <ArrowDown className="h-4 w-4 text-red-600" />
+                                                ) : (
+                                                    <Minus className="h-4 w-4 text-gray-500" />
+                                                )}
+                                            </TooltipTrigger>
+                                            <TooltipContent>
+                                                {getRankChangeTooltip(
+                                                    artist.rankChange,
+                                                    artist.previousRank,
+                                                )}
+                                            </TooltipContent>
+                                        </Tooltip>
+                                    </TooltipProvider>
+                                )}
+                            </div>
+                        </TableCell>
                         <TableCell>
                             <Link
                                 className="flex h-12 items-center gap-4 text-wrap underline-offset-4 hover:underline"
@@ -132,7 +209,14 @@ export async function TopArtists({
                                 {artist.artist}
                             </Link>
                         </TableCell>
-                        <TableCell>{artist.count}</TableCell>
+                        <TableCell>
+                            <div className="flex items-center gap-2">
+                                {artist.count}
+                                <PercentageBadge
+                                    percentChange={artist.percentChange}
+                                />
+                            </div>
+                        </TableCell>
                     </TableRow>
                 ))}
             </TableBody>
@@ -152,10 +236,13 @@ export async function TopTracks({
     "use cache";
 
     const timeFilters = getTimeFilters(dateRange);
+    const prevDateRange = getPrevDateRange(dateRange);
+    const prevTimeFilters = getTimeFilters(prevDateRange);
 
+    // Current period top tracks
     const topTracks = await db
         .select({
-            count: sql<number>`count(*)`,
+            count: count(),
             track: tracks.name,
             imageUrl: albums.imageUrl,
             trackId: tracks.id,
@@ -172,12 +259,44 @@ export async function TopTracks({
         )
         .groupBy(tracks.id, albums.imageUrl)
         .orderBy(
-            desc(sql`count(*)`),
-            desc(sql`sum(${listeningHistory.progressMs})`),
+            desc(count()),
+            desc(sum(listeningHistory.progressMs)),
             asc(tracks.name),
         )
         .limit(limit);
 
+    // Previous period top tracks
+    const prevTopTracks = await db
+        .select({
+            count: count(),
+            track: tracks.name,
+            trackId: tracks.id,
+        })
+        .from(listeningHistory)
+        .innerJoin(tracks, eq(listeningHistory.trackId, tracks.id))
+        .where(
+            and(
+                prevTimeFilters,
+                eq(listeningHistory.userId, userId),
+                gte(listeningHistory.progressMs, 30 * 1000),
+            ),
+        )
+        .groupBy(tracks.id)
+        .orderBy(
+            desc(count()),
+            desc(sum(listeningHistory.progressMs)),
+            asc(tracks.name),
+        )
+        .limit(limit);
+
+    // Calculate rank and count changes
+    const trackComparisons = calculateComparisons(
+        topTracks,
+        prevTopTracks,
+        "trackId",
+        "count",
+    );
+
     return (
         <Table>
             <TableHeader>
@@ -188,12 +307,37 @@ export async function TopTracks({
                 </TableHeadRow>
             </TableHeader>
             <TableBody>
-                {topTracks.map((track, index) => (
+                {trackComparisons.map((track, index) => (
                     <TableRow key={track.trackId}>
-                        <TableCell>{index + 1}</TableCell>
+                        <TableCell>
+                            <div className="flex items-center gap-2">
+                                {index + 1}
+                                {track.rankChange !== null && (
+                                    <TooltipProvider>
+                                        <Tooltip>
+                                            <TooltipTrigger>
+                                                {track.rankChange > 0 ? (
+                                                    <ArrowUp className="h-4 w-4 text-green-600" />
+                                                ) : track.rankChange < 0 ? (
+                                                    <ArrowDown className="h-4 w-4 text-red-600" />
+                                                ) : (
+                                                    <Minus className="h-4 w-4 text-gray-500" />
+                                                )}
+                                            </TooltipTrigger>
+                                            <TooltipContent>
+                                                {getRankChangeTooltip(
+                                                    track.rankChange,
+                                                    track.previousRank,
+                                                )}
+                                            </TooltipContent>
+                                        </Tooltip>
+                                    </TooltipProvider>
+                                )}
+                            </div>
+                        </TableCell>
                         <TableCell>
                             <Link
-                                className="flex items-center gap-4 text-wrap underline-offset-4 hover:underline"
+                                className="flex h-12 items-center gap-4 text-wrap underline-offset-4 hover:underline"
                                 href={`https://open.spotify.com/track/${track.trackId}`}
                                 target="_blank"
                             >
@@ -209,7 +353,14 @@ export async function TopTracks({
                                 {track.track}
                             </Link>
                         </TableCell>
-                        <TableCell>{track.count}</TableCell>
+                        <TableCell>
+                            <div className="flex items-center gap-2">
+                                {track.count}
+                                <PercentageBadge
+                                    percentChange={track.percentChange}
+                                />
+                            </div>
+                        </TableCell>
                     </TableRow>
                 ))}
             </TableBody>
@@ -229,10 +380,13 @@ export async function TopAlbums({
     "use cache";
 
     const timeFilters = getTimeFilters(dateRange);
+    const prevDateRange = getPrevDateRange(dateRange);
+    const prevTimeFilters = getTimeFilters(prevDateRange);
 
+    // Current period top albums
     const topAlbums = await db
         .select({
-            count: sql<number>`count(*)`,
+            count: count(),
             album: albums.name,
             imageUrl: albums.imageUrl,
             albumId: albums.id,
@@ -249,12 +403,45 @@ export async function TopAlbums({
         )
         .groupBy(albums.id, albums.imageUrl)
         .orderBy(
-            desc(sql`count(*)`),
-            desc(sql`sum(${listeningHistory.progressMs})`),
+            desc(count()),
+            desc(sum(listeningHistory.progressMs)),
             asc(albums.name),
         )
         .limit(limit);
 
+    // Previous period top albums
+    const prevTopAlbums = await db
+        .select({
+            count: count(),
+            album: albums.name,
+            albumId: albums.id,
+        })
+        .from(listeningHistory)
+        .innerJoin(tracks, eq(listeningHistory.trackId, tracks.id))
+        .innerJoin(albums, eq(tracks.albumId, albums.id))
+        .where(
+            and(
+                prevTimeFilters,
+                eq(listeningHistory.userId, userId),
+                gte(listeningHistory.progressMs, 30 * 1000),
+            ),
+        )
+        .groupBy(albums.id)
+        .orderBy(
+            desc(count()),
+            desc(sum(listeningHistory.progressMs)),
+            asc(albums.name),
+        )
+        .limit(limit);
+
+    // Calculate rank and count changes
+    const albumComparisons = calculateComparisons(
+        topAlbums,
+        prevTopAlbums,
+        "albumId",
+        "count",
+    );
+
     return (
         <Table>
             <TableHeader>
@@ -265,9 +452,34 @@ export async function TopAlbums({
                 </TableHeadRow>
             </TableHeader>
             <TableBody>
-                {topAlbums.map((album, index) => (
+                {albumComparisons.map((album, index) => (
                     <TableRow key={album.albumId}>
-                        <TableCell>{index + 1}</TableCell>
+                        <TableCell>
+                            <div className="flex items-center gap-2">
+                                {index + 1}
+                                {album.rankChange !== null && (
+                                    <TooltipProvider>
+                                        <Tooltip>
+                                            <TooltipTrigger>
+                                                {album.rankChange > 0 ? (
+                                                    <ArrowUp className="h-4 w-4 text-green-600" />
+                                                ) : album.rankChange < 0 ? (
+                                                    <ArrowDown className="h-4 w-4 text-red-600" />
+                                                ) : (
+                                                    <Minus className="h-4 w-4 text-gray-500" />
+                                                )}
+                                            </TooltipTrigger>
+                                            <TooltipContent>
+                                                {getRankChangeTooltip(
+                                                    album.rankChange,
+                                                    album.previousRank,
+                                                )}
+                                            </TooltipContent>
+                                        </Tooltip>
+                                    </TooltipProvider>
+                                )}
+                            </div>
+                        </TableCell>
                         <TableCell>
                             <Link
                                 className="flex h-12 items-center gap-4 text-wrap underline-offset-4 hover:underline"
@@ -286,7 +498,14 @@ export async function TopAlbums({
                                 {album.album}
                             </Link>
                         </TableCell>
-                        <TableCell>{album.count}</TableCell>
+                        <TableCell>
+                            <div className="flex items-center gap-2">
+                                {album.count}
+                                <PercentageBadge
+                                    percentChange={album.percentChange}
+                                />
+                            </div>
+                        </TableCell>
                     </TableRow>
                 ))}
             </TableBody>
diff --git a/src/app/dashboard/_components/totals.tsx b/src/app/dashboard/_components/totals.tsx
index c8c25e3..fdf401c 100644
--- a/src/app/dashboard/_components/totals.tsx
+++ b/src/app/dashboard/_components/totals.tsx
@@ -1,9 +1,36 @@
 import "server-only";
 
+import { PercentageBadge } from "@/components/percentage-badge";
 import { db } from "@/server/db";
 import { artistTracks, listeningHistory } from "@/server/db/schema";
-import { type DateRange, getTimeFilters } from "@/server/lib";
-import { and, eq, gte, sql } from "drizzle-orm";
+import { type DateRange, getPrevDateRange, getTimeFilters } from "@/server/lib";
+import { and, eq, gte, sql, type SQL } from "drizzle-orm";
+
+async function getMetricsWithComparison<T>(
+    userId: string,
+    dateRange: DateRange,
+    queryFn: (filters: SQL<unknown>, id: string) => Promise<T[]>,
+    getValueFromResult: (result: T[]) => number,
+): Promise<{
+    current: number;
+    previous: number;
+    percentChange: number | null;
+}> {
+    const timeFilters = getTimeFilters(dateRange)!;
+    const currentMetrics = await queryFn(timeFilters, userId);
+
+    const prevDateRange = getPrevDateRange(dateRange);
+    const prevTimeFilters = getTimeFilters(prevDateRange)!;
+    const prevMetrics = await queryFn(prevTimeFilters, userId);
+
+    const current = getValueFromResult(currentMetrics);
+    const previous = getValueFromResult(prevMetrics);
+
+    const percentChange =
+        previous > 0 ? ((current - previous) / previous) * 100 : null;
+
+    return { current, previous, percentChange };
+}
 
 export async function TotalMinutes({
     userId,
@@ -14,24 +41,32 @@ export async function TotalMinutes({
 }>) {
     "use cache";
 
-    const timeFilters = getTimeFilters(dateRange);
-
-    const totalProgressMs = await db
-        .select({
-            duration: sql<number>`sum(${listeningHistory.progressMs})`,
-        })
-        .from(listeningHistory)
-        .where(and(eq(listeningHistory.userId, userId), timeFilters));
-
-    let totalMinutes = 0;
-    if (totalProgressMs) {
-        totalMinutes = totalProgressMs[0]!.duration / 60000;
-    }
+    const { current, percentChange } = await getMetricsWithComparison(
+        userId,
+        dateRange,
+        async (filters: SQL<unknown>, id: string) => {
+            return db
+                .select({
+                    duration: sql<number>`sum(${listeningHistory.progressMs})`,
+                })
+                .from(listeningHistory)
+                .where(and(eq(listeningHistory.userId, id), filters));
+        },
+        (result) => {
+            if (result?.[0]?.duration) {
+                return result[0].duration / 60000;
+            }
+            return 0;
+        },
+    );
 
     return (
-        <p className="text-lg font-semibold">
-            {Math.round(totalMinutes).toLocaleString()}
-        </p>
+        <div className="flex items-center gap-2">
+            <p className="text-lg font-semibold">
+                {Math.round(current).toLocaleString()} mins
+            </p>
+            <PercentageBadge percentChange={percentChange} />
+        </div>
     );
 }
 
@@ -44,32 +79,40 @@ export async function TotalArtists({
 }>) {
     "use cache";
 
-    const timeFilters = getTimeFilters(dateRange);
-
-    const totalArtistsCount = await db
-        .select({
-            countArtists: sql<number>`count(distinct ${artistTracks.artistId})`,
-        })
-        .from(listeningHistory)
-        .innerJoin(
-            artistTracks,
-            eq(listeningHistory.trackId, artistTracks.trackId),
-        )
-        .where(
-            and(
-                eq(listeningHistory.userId, userId),
-                timeFilters,
-                gte(listeningHistory.progressMs, 30 * 1000),
-            ),
-        );
-
-    let totalArtists = 0;
-    if (totalArtistsCount) {
-        totalArtists = totalArtistsCount[0]!.countArtists;
-    }
+    const { current, percentChange } = await getMetricsWithComparison(
+        userId,
+        dateRange,
+        async (filters: SQL<unknown>, id: string) => {
+            return db
+                .select({
+                    countArtists: sql<number>`count(distinct ${artistTracks.artistId})`,
+                })
+                .from(listeningHistory)
+                .innerJoin(
+                    artistTracks,
+                    eq(listeningHistory.trackId, artistTracks.trackId),
+                )
+                .where(
+                    and(
+                        eq(listeningHistory.userId, id),
+                        filters,
+                        gte(listeningHistory.progressMs, 30 * 1000),
+                    ),
+                );
+        },
+        (result) => {
+            if (result?.[0]?.countArtists) {
+                return result[0].countArtists;
+            }
+            return 0;
+        },
+    );
 
     return (
-        <p className="text-lg font-semibold">{totalArtists.toLocaleString()}</p>
+        <div className="flex items-center gap-2">
+            <p className="text-lg font-semibold">{current.toLocaleString()}</p>
+            <PercentageBadge percentChange={percentChange} />
+        </div>
     );
 }
 
@@ -82,27 +125,35 @@ export async function TotalTracks({
 }>) {
     "use cache";
 
-    const timeFilters = getTimeFilters(dateRange);
-
-    const totalTracksCount = await db
-        .select({
-            countTracks: sql<number>`count(distinct ${listeningHistory.trackId})`,
-        })
-        .from(listeningHistory)
-        .where(
-            and(
-                eq(listeningHistory.userId, userId),
-                timeFilters,
-                gte(listeningHistory.progressMs, 30 * 1000),
-            ),
-        );
-
-    let totalTracks = 0;
-    if (totalTracksCount) {
-        totalTracks = totalTracksCount[0]!.countTracks;
-    }
+    const { current, percentChange } = await getMetricsWithComparison(
+        userId,
+        dateRange,
+        async (filters: SQL<unknown>, id: string) => {
+            return db
+                .select({
+                    countTracks: sql<number>`count(distinct ${listeningHistory.trackId})`,
+                })
+                .from(listeningHistory)
+                .where(
+                    and(
+                        eq(listeningHistory.userId, id),
+                        filters,
+                        gte(listeningHistory.progressMs, 30 * 1000),
+                    ),
+                );
+        },
+        (result) => {
+            if (result?.[0]?.countTracks) {
+                return result[0].countTracks;
+            }
+            return 0;
+        },
+    );
 
     return (
-        <p className="text-lg font-semibold">{totalTracks.toLocaleString()}</p>
+        <div className="flex items-center gap-2">
+            <p className="text-lg font-semibold">{current.toLocaleString()}</p>
+            <PercentageBadge percentChange={percentChange} />
+        </div>
     );
 }
diff --git a/src/app/feed/recent-listens.tsx b/src/app/feed/recent-listens.tsx
index b44b817..1477335 100644
--- a/src/app/feed/recent-listens.tsx
+++ b/src/app/feed/recent-listens.tsx
@@ -2,7 +2,13 @@
 
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 import { Card, CardContent } from "@/components/ui/card";
-import { timeAgo } from "@/lib/utils";
+import {
+    Tooltip,
+    TooltipContent,
+    TooltipProvider,
+    TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { formatFullTimestamp, timeAgo } from "@/lib/utils";
 import { Music } from "lucide-react";
 import Image from "next/image";
 import Link from "next/link";
@@ -12,6 +18,31 @@ import { getRecentListens, type RecentListen } from "./actions";
 
 const LISTENS_PER_PAGE = 10;
 
+function TimestampWithTooltip({
+    timestamp,
+    className,
+}: {
+    timestamp: string | Date;
+    className: string;
+}) {
+    return (
+        <TooltipProvider>
+            <Tooltip>
+                <TooltipTrigger asChild>
+                    <p
+                        className={`cursor-pointer text-xs text-muted-foreground ${className}`}
+                    >
+                        {timeAgo(new Date(timestamp))}
+                    </p>
+                </TooltipTrigger>
+                <TooltipContent>
+                    {formatFullTimestamp(new Date(timestamp))}
+                </TooltipContent>
+            </Tooltip>
+        </TooltipProvider>
+    );
+}
+
 export function RecentListens({
     initialState,
 }: Readonly<{ initialState: RecentListen[] }>) {
@@ -102,13 +133,14 @@ export function RecentListens({
                                         {listen.artist}
                                     </p>
                                 </Link>
-                                <p className="xs:hidden text-xs text-muted-foreground">
+                                <p className="text-xs text-muted-foreground xs:hidden">
                                     {timeAgo(new Date(listen.timestamp))}
                                 </p>
                             </div>
-                            <p className="xs:block hidden whitespace-nowrap text-xs text-muted-foreground">
-                                {timeAgo(new Date(listen.timestamp))}
-                            </p>
+                            <TimestampWithTooltip
+                                timestamp={new Date(listen.timestamp)}
+                                className="hidden whitespace-nowrap xs:block"
+                            />
                         </div>
                     ))}
                 </div>
diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx
index 8259784..f005f13 100644
--- a/src/app/leaderboard/page.tsx
+++ b/src/app/leaderboard/page.tsx
@@ -1,3 +1,4 @@
+import { PercentageBadge } from "@/components/percentage-badge";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Label } from "@/components/ui/label";
 import {
@@ -18,12 +19,24 @@ import {
     TableHeadRow,
     TableRow,
 } from "@/components/ui/table";
+import {
+    Tooltip,
+    TooltipContent,
+    TooltipProvider,
+    TooltipTrigger,
+} from "@/components/ui/tooltip";
 import { formatDuration } from "@/lib/utils";
 import { db } from "@/server/db";
 import * as schema from "@/server/db/schema";
-import { getBaseUrl, getUserFriends } from "@/server/lib";
+import {
+    calculateComparisons,
+    getBaseUrl,
+    getRankChangeTooltip,
+    getUserFriends,
+} from "@/server/lib";
 import { auth, clerkClient } from "@clerk/nextjs/server";
-import { and, desc, gte, inArray, type SQL, sql } from "drizzle-orm";
+import { and, desc, gte, inArray, lt, type SQL, sql } from "drizzle-orm";
+import { ArrowDown, ArrowUp, Minus } from "lucide-react";
 import Image from "next/image";
 import Link from "next/link";
 import { redirect } from "next/navigation";
@@ -68,28 +81,29 @@ export default async function LeaderboardPage({
             : "Last 7 days"
     ) as Timeframe;
 
+    // Current period filters
     const filters: SQL[] = [];
 
+    let timeStart: Date | null = null;
     if (timeframe === "Last 7 days") {
-        filters.push(
-            gte(
-                schema.listeningHistory.playedAt,
-                new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000),
-            ),
-        );
+        timeStart = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000);
+        filters.push(gte(schema.listeningHistory.playedAt, timeStart));
     } else if (timeframe === "Last 30 days") {
-        filters.push(
-            gte(
-                schema.listeningHistory.playedAt,
-                new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000),
-            ),
-        );
+        timeStart = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000);
+        filters.push(gte(schema.listeningHistory.playedAt, timeStart));
     } else if (timeframe === "Last 24 hours") {
-        filters.push(
-            gte(
-                schema.listeningHistory.playedAt,
-                new Date(new Date().getTime() - 24 * 60 * 60 * 1000),
-            ),
+        timeStart = new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
+        filters.push(gte(schema.listeningHistory.playedAt, timeStart));
+    }
+
+    // Previous period filters
+    const prevFilters: SQL[] = [];
+    if (timeStart) {
+        const prevTimeSpan = timeStart.getTime() - new Date().getTime();
+        const prevTimeStart = new Date(timeStart.getTime() + prevTimeSpan);
+        prevFilters.push(
+            gte(schema.listeningHistory.playedAt, prevTimeStart),
+            lt(schema.listeningHistory.playedAt, timeStart),
         );
     }
 
@@ -99,6 +113,11 @@ export default async function LeaderboardPage({
     } else {
         metricQuery = sql<number>`count(*)`;
         filters.push(gte(schema.listeningHistory.progressMs, 30 * 1000));
+        if (prevFilters.length > 0) {
+            prevFilters.push(
+                gte(schema.listeningHistory.progressMs, 30 * 1000),
+            );
+        }
     }
 
     // Get the number of entries to fetch from the search params (per page)
@@ -151,6 +170,7 @@ export default async function LeaderboardPage({
         }
     }
 
+    // Current period leaderboard
     const leaderboardUsers = await db
         .select({
             userId: schema.listeningHistory.userId,
@@ -169,7 +189,50 @@ export default async function LeaderboardPage({
         .limit(limit)
         .offset((page - 1) * limit);
 
-    const userIds = leaderboardUsers.map((user) => user.userId);
+    // Ensure metric is a number in each result - for some reason it was being returned as a string
+    const formattedLeaderboardUsers = leaderboardUsers.map((user) => ({
+        ...user,
+        metric: Number(user.metric),
+    }));
+
+    // Previous period leaderboard (for comparison)
+    const prevLeaderboardUsers =
+        prevFilters.length > 0
+            ? await db
+                  .select({
+                      userId: schema.listeningHistory.userId,
+                      metric: metricQuery.as("metric"),
+                  })
+                  .from(schema.listeningHistory)
+                  .where(
+                      and(
+                          ...prevFilters,
+                          // Only include the current user and their friends
+                          inArray(
+                              schema.listeningHistory.userId,
+                              allowedUserIds,
+                          ),
+                      ),
+                  )
+                  .groupBy(schema.listeningHistory.userId)
+                  .orderBy(desc(metricQuery))
+            : [];
+
+    // Ensure metric is a number in each previous result
+    const formattedPrevLeaderboardUsers = prevLeaderboardUsers.map((user) => ({
+        ...user,
+        metric: Number(user.metric),
+    }));
+
+    // Calculate comparisons
+    const userComparisons = calculateComparisons(
+        formattedLeaderboardUsers,
+        formattedPrevLeaderboardUsers,
+        "userId",
+        "metric",
+    );
+
+    const userIds = formattedLeaderboardUsers.map((user) => user.userId);
 
     const client = await clerkClient();
     const { data: userData } = await client.users.getUserList({
@@ -266,14 +329,42 @@ export default async function LeaderboardPage({
                                 </TableHeadRow>
                             </TableHeader>
                             <TableBody>
-                                {leaderboardUsers.map((user, index) => {
+                                {userComparisons.map((user, index) => {
                                     const clerkUser = userData.find(
                                         (innerUser) =>
                                             innerUser.id === user.userId,
                                     );
                                     return (
                                         <TableRow key={user.userId}>
-                                            <TableCell>{index + 1}</TableCell>
+                                            <TableCell>
+                                                <div className="flex items-center gap-2">
+                                                    {index + 1}
+                                                    {user.rankChange !==
+                                                        null && (
+                                                        <TooltipProvider>
+                                                            <Tooltip>
+                                                                <TooltipTrigger>
+                                                                    {user.rankChange >
+                                                                    0 ? (
+                                                                        <ArrowUp className="h-4 w-4 text-green-600" />
+                                                                    ) : user.rankChange <
+                                                                      0 ? (
+                                                                        <ArrowDown className="h-4 w-4 text-red-600" />
+                                                                    ) : (
+                                                                        <Minus className="h-4 w-4 text-gray-500" />
+                                                                    )}
+                                                                </TooltipTrigger>
+                                                                <TooltipContent>
+                                                                    {getRankChangeTooltip(
+                                                                        user.rankChange,
+                                                                        user.previousRank,
+                                                                    )}
+                                                                </TooltipContent>
+                                                            </Tooltip>
+                                                        </TooltipProvider>
+                                                    )}
+                                                </div>
+                                            </TableCell>
                                             <TableCell>
                                                 <Link
                                                     className="flex h-12 items-center gap-4 text-wrap underline-offset-4 hover:underline"
@@ -295,13 +386,20 @@ export default async function LeaderboardPage({
                                                 </Link>
                                             </TableCell>
                                             <TableCell>
-                                                {sortBy === "Playtime"
-                                                    ? formatDuration(
-                                                          user.metric,
-                                                      )
-                                                    : Number(
-                                                          user.metric,
-                                                      ).toLocaleString()}
+                                                <div className="flex items-center gap-2">
+                                                    {sortBy === "Playtime"
+                                                        ? formatDuration(
+                                                              user.metric,
+                                                          )
+                                                        : Number(
+                                                              user.metric,
+                                                          ).toLocaleString()}
+                                                    <PercentageBadge
+                                                        percentChange={
+                                                            user.percentChange
+                                                        }
+                                                    />
+                                                </div>
                                             </TableCell>
                                         </TableRow>
                                     );
diff --git a/src/components/LineChart.tsx b/src/components/LineChart.tsx
index 60d4d2f..c8e53e7 100644
--- a/src/components/LineChart.tsx
+++ b/src/components/LineChart.tsx
@@ -485,11 +485,14 @@ type BaseEventProps = {
 
 type LineChartEventProps = BaseEventProps | null | undefined;
 
+type LineStyle = "solid" | "dashed" | "dotted";
+
 interface LineChartProps extends React.HTMLAttributes<HTMLDivElement> {
     data: Record<string, any>[];
     index: string;
     categories: string[];
     colors?: AvailableChartColorsKeys[];
+    lineStyles?: Record<string, LineStyle>;
     valueFormatter?: (value: number) => string;
     startEndOnly?: boolean;
     showXAxis?: boolean;
@@ -521,6 +524,7 @@ const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
             categories = [],
             index,
             colors = AvailableChartColors,
+            lineStyles = {},
             valueFormatter = (value: number) => value.toString(),
             startEndOnly = false,
             showXAxis = true,
@@ -796,94 +800,45 @@ const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
                                 }
                             />
                         ) : null}
-                        {categories.map((category) => (
-                            <Line
-                                className={cx(
-                                    getColorClassName(
-                                        categoryColors.get(category)!,
-                                        "stroke",
-                                    ),
-                                )}
-                                strokeOpacity={
-                                    activeDot ||
-                                    (activeLegend && activeLegend !== category)
-                                        ? 0.3
-                                        : 1
-                                }
-                                activeDot={(props: any) => {
-                                    const {
-                                        cx: cxCoord,
-                                        cy: cyCoord,
-                                        stroke,
-                                        strokeLinecap,
-                                        strokeLinejoin,
-                                        strokeWidth,
-                                        dataKey,
-                                    } = props;
-                                    return (
-                                        <Dot
-                                            className={cx(
-                                                "stroke-white dark:stroke-gray-950",
-                                                onValueChange
-                                                    ? "cursor-pointer"
-                                                    : "",
-                                                getColorClassName(
-                                                    categoryColors.get(
-                                                        dataKey,
-                                                    )!,
-                                                    "fill",
-                                                ),
-                                            )}
-                                            cx={cxCoord}
-                                            cy={cyCoord}
-                                            r={5}
-                                            fill=""
-                                            stroke={stroke}
-                                            strokeLinecap={strokeLinecap}
-                                            strokeLinejoin={strokeLinejoin}
-                                            strokeWidth={strokeWidth}
-                                            onClick={(_, event) =>
-                                                onDotClick(props, event)
-                                            }
-                                        />
-                                    );
-                                }}
-                                dot={(props: any) => {
-                                    const {
-                                        stroke,
-                                        strokeLinecap,
-                                        strokeLinejoin,
-                                        strokeWidth,
-                                        cx: cxCoord,
-                                        cy: cyCoord,
-                                        dataKey,
-                                        index,
-                                    } = props;
-
-                                    if (
-                                        (hasOnlyOneValueForKey(
-                                            data,
-                                            category,
-                                        ) &&
-                                            !(
-                                                activeDot ||
-                                                (activeLegend &&
-                                                    activeLegend !== category)
-                                            )) ||
-                                        (activeDot?.index === index &&
-                                            activeDot?.dataKey === category)
-                                    ) {
+                        {categories.map((category) => {
+                            const lineStyle = lineStyles[category] ?? "solid";
+
+                            const strokeDashConfig = {
+                                solid: "",
+                                dashed: "5 5",
+                                dotted: "2 2",
+                            };
+
+                            return (
+                                <Line
+                                    className={cx(
+                                        getColorClassName(
+                                            categoryColors.get(category)!,
+                                            "stroke",
+                                        ),
+                                    )}
+                                    strokeOpacity={
+                                        activeDot ||
+                                        (activeLegend &&
+                                            activeLegend !== category)
+                                            ? 0.3
+                                            : 1
+                                    }
+                                    strokeDasharray={
+                                        strokeDashConfig[lineStyle]
+                                    }
+                                    activeDot={(props: any) => {
+                                        const {
+                                            cx: cxCoord,
+                                            cy: cyCoord,
+                                            stroke,
+                                            strokeLinecap,
+                                            strokeLinejoin,
+                                            strokeWidth,
+                                            dataKey,
+                                        } = props;
                                         return (
                                             <Dot
-                                                key={index}
-                                                cx={cxCoord}
-                                                cy={cyCoord}
-                                                r={5}
-                                                stroke={stroke}
-                                                fill=""
-                                                strokeLinecap={strokeLinecap}
-                                                strokeLinejoin={strokeLinejoin}
-                                                strokeWidth={strokeWidth}
                                                 className={cx(
                                                     "stroke-white dark:stroke-gray-950",
                                                     onValueChange
@@ -896,27 +851,95 @@ const LineChart = React.forwardRef<HTMLDivElement, LineChartProps>(
                                                         "fill",
                                                     ),
                                                 )}
+                                                cx={cxCoord}
+                                                cy={cyCoord}
+                                                r={5}
+                                                fill=""
+                                                stroke={stroke}
+                                                strokeLinecap={strokeLinecap}
+                                                strokeLinejoin={strokeLinejoin}
+                                                strokeWidth={strokeWidth}
+                                                onClick={(_, event) =>
+                                                    onDotClick(props, event)
+                                                }
                                             />
                                         );
-                                    }
-                                    return (
-                                        <React.Fragment
-                                            key={index}
-                                        ></React.Fragment>
-                                    );
-                                }}
-                                key={category}
-                                name={category}
-                                type="linear"
-                                dataKey={category}
-                                stroke=""
-                                strokeWidth={2}
-                                strokeLinejoin="round"
-                                strokeLinecap="round"
-                                isAnimationActive={false}
-                                connectNulls={connectNulls}
-                            />
-                        ))}
+                                    }}
+                                    dot={(props: any) => {
+                                        const {
+                                            stroke,
+                                            strokeLinecap,
+                                            strokeLinejoin,
+                                            strokeWidth,
+                                            cx: cxCoord,
+                                            cy: cyCoord,
+                                            dataKey,
+                                            index,
+                                        } = props;
+
+                                        if (
+                                            (hasOnlyOneValueForKey(
+                                                data,
+                                                category,
+                                            ) &&
+                                                !(
+                                                    activeDot ||
+                                                    (activeLegend &&
+                                                        activeLegend !==
+                                                            category)
+                                                )) ||
+                                            (activeDot?.index === index &&
+                                                activeDot?.dataKey === category)
+                                        ) {
+                                            return (
+                                                <Dot
+                                                    key={index}
+                                                    cx={cxCoord}
+                                                    cy={cyCoord}
+                                                    r={5}
+                                                    stroke={stroke}
+                                                    fill=""
+                                                    strokeLinecap={
+                                                        strokeLinecap
+                                                    }
+                                                    strokeLinejoin={
+                                                        strokeLinejoin
+                                                    }
+                                                    strokeWidth={strokeWidth}
+                                                    className={cx(
+                                                        "stroke-white dark:stroke-gray-950",
+                                                        onValueChange
+                                                            ? "cursor-pointer"
+                                                            : "",
+                                                        getColorClassName(
+                                                            categoryColors.get(
+                                                                dataKey,
+                                                            )!,
+                                                            "fill",
+                                                        ),
+                                                    )}
+                                                />
+                                            );
+                                        }
+                                        return (
+                                            <React.Fragment
+                                                key={index}
+                                            ></React.Fragment>
+                                        );
+                                    }}
+                                    key={category}
+                                    name={category}
+                                    type="linear"
+                                    dataKey={category}
+                                    stroke=""
+                                    strokeWidth={2}
+                                    strokeLinejoin="round"
+                                    strokeLinecap="round"
+                                    isAnimationActive={false}
+                                    connectNulls={connectNulls}
+                                />
+                            );
+                        })}
                         {/* hidden lines to increase clickable target area */}
                         {onValueChange
                             ? categories.map((category) => (
diff --git a/src/components/percentage-badge.tsx b/src/components/percentage-badge.tsx
new file mode 100644
index 0000000..cb1bc87
--- /dev/null
+++ b/src/components/percentage-badge.tsx
@@ -0,0 +1,32 @@
+import { Badge } from "@/components/ui/badge";
+import { ArrowDown, ArrowUp, Minus } from "lucide-react";
+
+export function PercentageBadge({
+    percentChange,
+}: {
+    percentChange: number | null;
+}) {
+    if (percentChange === null) return null;
+
+    return (
+        <Badge
+            variant="outline"
+            className={`flex items-center gap-1 px-1.5 py-0.5 text-xs ${
+                percentChange > 0
+                    ? "border-green-600/30 bg-green-600/10 text-green-600"
+                    : percentChange < 0
+                      ? "border-red-600/30 bg-red-600/10 text-red-600"
+                      : "border-gray-500/30 bg-gray-500/10 text-gray-500"
+            } `}
+        >
+            {percentChange > 0 ? (
+                <ArrowUp className="h-3 w-3" />
+            ) : percentChange < 0 ? (
+                <ArrowDown className="h-3 w-3" />
+            ) : (
+                <Minus className="h-3 w-3" />
+            )}
+            {Math.abs(percentChange).toFixed(1)}%
+        </Badge>
+    );
+}
diff --git a/src/components/ui-parts/PlaytimeChart.tsx b/src/components/ui-parts/PlaytimeChart.tsx
index 4b86fdf..069651c 100644
--- a/src/components/ui-parts/PlaytimeChart.tsx
+++ b/src/components/ui-parts/PlaytimeChart.tsx
@@ -11,10 +11,39 @@ export interface DailyPlaytime {
 export function PlaytimeChart(
     props: Readonly<{
         dailyPlaytime: DailyPlaytime[];
+        prevDailyPlaytime?: DailyPlaytime[];
     }>,
 ) {
+    // If no previous data, just return current data
+    if (!props.prevDailyPlaytime) {
+        return (
+            <LineChart
+                className="h-80"
+                data={props.dailyPlaytime}
+                index="date"
+                categories={["playtime"]}
+                colors={["blue"]}
+                valueFormatter={formatDuration}
+                yAxisWidth={60}
+            />
+        );
+    }
+
+    // Combine the data into a format that supports multiple lines
+    const combinedData = props.dailyPlaytime.map((item, index) => {
+        const prevItem = props.prevDailyPlaytime![index] ?? { playtime: 0 };
+        return {
+            date: item.date,
+            "Current Period": item.playtime,
+            "Previous Period": prevItem.playtime,
+        };
+    });
+
+    // Calculate max playtime across both current and previous periods
     const maxPlaytime = Math.max(
-        ...props.dailyPlaytime.map((item) => item.playtime),
+        ...combinedData.map((item) =>
+            Math.max(item["Current Period"], item["Previous Period"]),
+        ),
     );
 
     // Get the order of magnitude of the max playtime
@@ -23,16 +52,23 @@ export function PlaytimeChart(
     return (
         <LineChart
             className="h-80"
-            data={props.dailyPlaytime}
+            data={combinedData}
             index="date"
-            categories={["playtime"]}
+            categories={["Current Period", "Previous Period"]}
+            colors={["blue", "gray"]}
             showLegend={true}
+            onValueChange={() => null}
             minValue={0}
             maxValue={
                 Math.ceil(maxPlaytime / 10 ** orderOfMagnitude) *
                 10 ** orderOfMagnitude
             }
             valueFormatter={formatDuration}
+            yAxisWidth={60}
+            lineStyles={{
+                "Current Period": "solid",
+                "Previous Period": "dashed",
+            }}
         />
     );
 }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..6277b1a
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+    "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+    {
+        variants: {
+            variant: {
+                default:
+                    "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+                secondary:
+                    "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+                destructive:
+                    "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+                outline: "text-foreground",
+            },
+        },
+        defaultVariants: {
+            variant: "default",
+        },
+    },
+);
+
+export interface BadgeProps
+    extends React.HTMLAttributes<HTMLDivElement>,
+        VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+    return (
+        <div className={cn(badgeVariants({ variant }), className)} {...props} />
+    );
+}
+
+export { Badge, badgeVariants };
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..3215128
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+    React.ElementRef<typeof TooltipPrimitive.Content>,
+    React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+    <TooltipPrimitive.Portal>
+        <TooltipPrimitive.Content
+            ref={ref}
+            sideOffset={sideOffset}
+            className={cn(
+                "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+                className,
+            )}
+            {...props}
+        />
+    </TooltipPrimitive.Portal>
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 223f8bd..900c6d2 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -26,6 +26,18 @@ export function timeAgo(date: Date) {
     return days === 1 ? "1 day ago" : `${days} days ago`;
 }
 
+export function formatFullTimestamp(date: Date) {
+    return date.toLocaleString(undefined, {
+        weekday: "long",
+        year: "numeric",
+        month: "long",
+        day: "numeric",
+        hour: "2-digit",
+        minute: "2-digit",
+        second: "2-digit",
+    });
+}
+
 export function dateFormatter(date: Date) {
     // Return the date in the format "YYYY-MM-DD"
     return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
@@ -51,6 +63,22 @@ export function formatDuration(duration: number) {
     return formatted.trim();
 }
 
+// Ordinal functions
+const englishOrdinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
+const suffixes = {
+    one: "st",
+    two: "nd",
+    few: "rd",
+    other: "th",
+    zero: "",
+    many: "",
+};
+
+export function ordinal(n: number) {
+    const rule = englishOrdinalRules.select(n);
+    return `${n}${suffixes[rule]}`;
+}
+
 // Tremor Raw cx [v0.0.0]
 
 import clsx, { type ClassValue } from "clsx";
diff --git a/src/server/lib.ts b/src/server/lib.ts
index 3580323..009fbde 100644
--- a/src/server/lib.ts
+++ b/src/server/lib.ts
@@ -1,3 +1,4 @@
+import { ordinal } from "@/lib/utils";
 import { db } from "@/server/db";
 import * as schema from "@/server/db/schema";
 import { type clerkClient } from "@clerk/nextjs/server";
@@ -33,6 +34,16 @@ export function getTimeFilters(dateRange: DateRange) {
     return timeFilters;
 }
 
+export function getPrevDateRange(currRange: DateRange) {
+    const dateDiff = Math.abs(
+        currRange.to.getTime() - currRange.from.getTime(),
+    );
+    return {
+        to: currRange.from,
+        from: new Date(currRange.from.getTime() - dateDiff),
+    } satisfies DateRange;
+}
+
 export async function setUserTracking(
     enabled: boolean,
     userId: string,
@@ -152,3 +163,62 @@ export async function getUserFriends(userId: string) {
 
     return friends.map((friend) => friend.friendId);
 }
+
+export function calculateComparisons<
+    TCurrent extends Record<string, unknown>,
+    TPrev extends Record<string, unknown>,
+    TValueKey extends string | number | symbol,
+>(
+    currentItems: Array<TCurrent>,
+    previousItems: Array<TPrev>,
+    idKey: keyof TCurrent & keyof TPrev,
+    valueKey: TValueKey & keyof TCurrent & keyof TPrev,
+) {
+    return currentItems.map((item, currentIndex) => {
+        // Find this item in previous period
+        const prevItemIndex = previousItems.findIndex(
+            // @ts-expect-error Based on usage, this is fine
+            (prevItem) => prevItem[idKey] === item[idKey],
+        );
+
+        // Determine rank change
+        const rankChange =
+            prevItemIndex !== -1 ? prevItemIndex - currentIndex : null;
+
+        // Determine value change
+        const prevItem =
+            prevItemIndex !== -1 ? previousItems[prevItemIndex] : null;
+        const valueChange =
+            prevItem &&
+            typeof item[valueKey] === "number" &&
+            typeof prevItem[valueKey] === "number"
+                ? Number(prevItem[valueKey]) !== 0
+                    ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) /
+                          Number(prevItem[valueKey])) *
+                      100
+                    : 0
+                : null;
+
+        return {
+            ...item, // This preserves all properties from the current item
+            rankChange,
+            percentChange: valueChange,
+            previousRank: prevItemIndex !== -1 ? prevItemIndex + 1 : null,
+        };
+    });
+}
+
+export function getRankChangeTooltip(
+    rankChange: number | null,
+    previousRank: number | null,
+): string {
+    if (rankChange === null || previousRank === null) return "";
+
+    if (rankChange > 0) {
+        return `Moved up ${rankChange} rank${rankChange !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
+    } else if (rankChange < 0) {
+        return `Moved down ${Math.abs(rankChange)} rank${Math.abs(rankChange) !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
+    } else {
+        return "Same rank as previous period";
+    }
+}
diff --git a/src/server/spotify/spotify.ts b/src/server/spotify/spotify.ts
index 490bd81..f468ebe 100644
--- a/src/server/spotify/spotify.ts
+++ b/src/server/spotify/spotify.ts
@@ -1,6 +1,7 @@
 import { env } from "@/env";
 import { clerkClient } from "@clerk/nextjs/server";
 import { unstable_cacheLife as cacheLife } from "next/cache";
+import "server-only";
 import { getSpotifyToken } from "../lib";
 import type {
     Albums,


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (10)</summary><blockquote>

<details>
<summary>src/server/lib.ts (1)</summary><blockquote>

`36-44`: **Consider adding validation for the input date range.**

The function logic is sound for calculating a previous period with the same duration as the current period. However, it assumes that `from` date is before `to` date, which might not always be the case.


Consider adding a validation check:

```diff
export function getPrevDateRange(currRange: DateRange) {
+   if (currRange.from > currRange.to) {
+       throw new Error("Invalid date range: 'from' date must be before 'to' date");
+   }
    const dateDiff = Math.abs(
        currRange.to.getTime() - currRange.from.getTime(),
    );
    return {
        to: currRange.from,
        from: new Date(currRange.from.getTime() - dateDiff),
    } satisfies DateRange;
}
```

</blockquote></details>
<details>
<summary>src/components/ui/tooltip.tsx (1)</summary><blockquote>

`1-32`: **Well-structured tooltip component implementation.**

The tooltip component is well-implemented using Radix UI's primitives. It follows the project's component patterns with proper use of `React.forwardRef`, appropriate styling with the `cn` utility, and proper animation states.



Consider adding an accessibility enhancement to better support screen readers:

```diff
const TooltipContent = React.forwardRef<
    React.ElementRef<typeof TooltipPrimitive.Content>,
    React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
    <TooltipPrimitive.Portal>
        <TooltipPrimitive.Content
            ref={ref}
            sideOffset={sideOffset}
            className={cn(
                "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
                className,
            )}
+           aria-live="polite"
            {...props}
        />
    </TooltipPrimitive.Portal>
));
```

</blockquote></details>
<details>
<summary>src/app/api/update-now-playing/route.ts (1)</summary><blockquote>

`88-95`: **Consider resolving the podcast TODO comment.**

There's a TODO comment about ignoring podcasts that should be addressed.

Would you like assistance in implementing podcast support or making a more definitive decision about handling podcast content?

</blockquote></details>
<details>
<summary>src/components/ui/badge.tsx (1)</summary><blockquote>

`30-33`: **Consider enhancing accessibility with semantic HTML.**

The Badge component uses a `div` element, which doesn't convey semantic meaning. For better accessibility, consider using a more appropriate element like `span` or adding appropriate ARIA attributes.

```diff
-function Badge({ className, variant, ...props }: BadgeProps) {
-    return (
-        <div className={cn(badgeVariants({ variant }), className)} {...props} />
-    );
-}
+function Badge({ className, variant, ...props }: BadgeProps) {
+    return (
+        <span 
+            role="status" 
+            className={cn(badgeVariants({ variant }), className)} 
+            {...props} 
+        />
+    );
+}
```

</blockquote></details>
<details>
<summary>src/components/LineChart.tsx (2)</summary><blockquote>

`803-942`: **Line styling implementation is comprehensive but could be refactored.**

The implementation of line styles with different dash patterns is well done, but the nested JSX structure in this section is quite complex and makes the code harder to read and maintain.

Consider extracting the Line component rendering into a separate function to improve readability:

```diff
+ const renderLineComponent = (category: string, categoryColors: Map<string, AvailableChartColorsKeys>, activeDot: ActiveDot | undefined, activeLegend: string | undefined) => {
+     const lineStyle = lineStyles[category] ?? "solid";
+     
+     const strokeDashConfig = {
+         solid: "",
+         dashed: "5 5",
+         dotted: "2 2",
+     };
+     
+     return (
+         <Line
+             className={cx(
+                 getColorClassName(
+                     categoryColors.get(category)!,
+                     "stroke",
+                 ),
+             )}
+             strokeOpacity={
+                 activeDot ||
+                 (activeLegend &&
+                     activeLegend !== category)
+                     ? 0.3
+                     : 1
+             }
+             strokeDasharray={strokeDashConfig[lineStyle]}
+             activeDot={/* activeDot implementation */}
+             dot={/* dot implementation */}
+             key={category}
+             name={category}
+             type="linear"
+             dataKey={category}
+             stroke=""
+             strokeWidth={2}
+             strokeLinejoin="round"
+             strokeLinecap="round"
+             isAnimationActive={false}
+             connectNulls={connectNulls}
+         />
+     );
+ };

- {categories.map((category) => {
-     const lineStyle = lineStyles[category] ?? "solid";
-     
-     const strokeDashConfig = {
-         solid: "",
-         dashed: "5 5",
-         dotted: "2 2",
-     };
-     
-     return (
-         <Line /* full implementation */ />
-     );
- })}
+ {categories.map((category) => renderLineComponent(category, categoryColors, activeDot, activeLegend))}
```

---

`806-810`: **Stroke dash configuration could be moved outside render function.**

The stroke dash configuration is recreated on each render and for each category. For better performance, consider defining it as a constant outside the component or memoizing it.

```diff
+const STROKE_DASH_CONFIG = {
+    solid: "",
+    dashed: "5 5",
+    dotted: "2 2",
+};

// Then inside the map function:
-const strokeDashConfig = {
-    solid: "",
-    dashed: "5 5",
-    dotted: "2 2",
-};
-
-return (
-    // ...
-    strokeDasharray={strokeDashConfig[lineStyle]}
-    // ...
-);
+return (
+    // ...
+    strokeDasharray={STROKE_DASH_CONFIG[lineStyle]}
+    // ...
+);
```

</blockquote></details>
<details>
<summary>src/app/dashboard/_components/totals.tsx (1)</summary><blockquote>

`62-66`: **Extract badge styling to avoid duplication.**

The Badge styling logic is duplicated across all three components. Consider extracting it to a utility function or custom component to improve maintainability.

Create a shared component for percentage change badges:

```typescript
function PercentageBadge({ percentChange }: { percentChange: number | null }) {
  if (percentChange === null) return null;
  
  const isPositive = percentChange > 0;
  
  return (
    <Badge
      variant="outline"
      className={`flex items-center gap-1 px-2 py-0.5 text-xs ${
        isPositive
          ? "border-green-600/30 bg-green-600/10 text-green-600"
          : "border-red-600/30 bg-red-600/10 text-red-600"
      }`}
    >
      {isPositive ? (
        <ArrowUp className="h-3 w-3" />
      ) : (
        <ArrowDown className="h-3 w-3" />
      )}
      {Math.abs(percentChange).toFixed(1)}%
    </Badge>
  );
}
```

Then use it in each component:

```jsx
<div className="flex items-center gap-2">
  <p className="text-lg font-semibold">{totalMinutes.toLocaleString()} mins</p>
  <PercentageBadge percentChange={percentChange} />
</div>
```


Also applies to: 152-156, 234-238

</blockquote></details>
<details>
<summary>src/components/ui-parts/PlaytimeChart.tsx (2)</summary><blockquote>

`17-30`: **Consider showing a message if `prevDailyPlaytime` is absent.**  
Right now, it gracefully falls back to a single line chart. As an optional enhancement, you could display a small note explaining that comparison data is unavailable.

---

`33-40`: **Combine data by date key rather than index.**  
Index-based merging may show mismatched comparisons if the arrays differ in length or ordering. Refactoring to merge by date can provide more accurate alignment.

</blockquote></details>
<details>
<summary>src/app/dashboard/_components/daily-playtime-graph.tsx (1)</summary><blockquote>

`221-226`: **Passing both daily and previous daily data to `PlaytimeChart`.**  
Consider verifying that both arrays align by date or index; otherwise, data points might misalign in the chart.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 7f7037df40127a32542ed9954578ee7f8ee00700 and 3c40b16201419cb246c2adbb1fe4c7871a063009.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml`

</details>

<details>
<summary>📒 Files selected for processing (12)</summary>

* `package.json` (1 hunks)
* `src/app/api/update-now-playing/route.ts` (3 hunks)
* `src/app/dashboard/_components/daily-playtime-graph.tsx` (4 hunks)
* `src/app/dashboard/_components/top-tables.tsx` (15 hunks)
* `src/app/dashboard/_components/totals.tsx` (4 hunks)
* `src/components/LineChart.tsx` (4 hunks)
* `src/components/ui-parts/PlaytimeChart.tsx` (2 hunks)
* `src/components/ui/badge.tsx` (1 hunks)
* `src/components/ui/tooltip.tsx` (1 hunks)
* `src/lib/utils.ts` (1 hunks)
* `src/server/lib.ts` (1 hunks)
* `src/server/spotify/spotify.ts` (1 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* src/server/spotify/spotify.ts

</details>

<details>
<summary>🔇 Additional comments (37)</summary><blockquote>

<details>
<summary>package.json (1)</summary>

`36-36`: **Dependency addition looks appropriate.**

The addition of `@radix-ui/react-tooltip` aligns with the PR objective of implementing tooltip functionality. The version is properly pinned with the caret notation consistent with other dependencies in the project.

</details>
<details>
<summary>src/lib/utils.ts (1)</summary>

`54-68`: <details>
<summary>✅ Verification successful</summary>

**Well-implemented ordinal function using internationalization API.**

The ordinal function is nicely implemented using the `Intl.PluralRules` API, which provides good language support. This will be useful for displaying rankings and position numbers in the UI.



---

🏁 Script executed:

```shell
#!/bin/bash
# Check where the ordinal function might be used in the codebase

# Look for imports of the ordinal function
rg "import.*ordinal.*from" -A 2 -B 2

# Look for usages of the ordinal function
rg "\bordinal\s*\(" -A 2 -B 2
```

Length of output: 3721

---



</details>

**Ordinal Function Verified: Well-Implemented and Correctly Utilized**

The implementation of the `ordinal` function in `src/lib/utils.ts` is solid, utilizing the `Intl.PluralRules` API effectively. Verification confirms that this function is correctly imported and used within `src/app/dashboard/_components/top-tables.tsx` for displaying rankings.

- Verified usage in `src/app/dashboard/_components/top-tables.tsx`
- Implementation in `src/lib/utils.ts` correctly applies internationalization for ordinal formatting

</details>
<details>
<summary>src/app/api/update-now-playing/route.ts (2)</summary>

`7-7`: **API implementation simplification - good change.**

The import update to `getUserPlaying` instead of the previous `getCurrentlyPlaying` reflects a positive architectural change in the Spotify API access pattern.

---

`74-74`: **Simplified Spotify API flow by removing token handling.**

This change improves the code by removing token management complexity. Now the function directly uses the user ID to fetch playing data instead of requiring token handling and associated error management. This is a good architectural improvement that:

1. Reduces complexity
2. Improves maintainability
3. Eliminates potential token-related errors

The error logging has also been properly updated to match this new implementation.



Also applies to: 83-83

</details>
<details>
<summary>src/components/ui/badge.tsx (3)</summary>

`1-5`: **Imports implemented correctly.**

The imports are properly structured with clear separation between external and internal dependencies.

---

`6-24`: **Well-implemented styling variants for the badge component.**

The badge variants are properly defined using the `cva` function, with clear semantics for different use cases. The variant styles (default, secondary, destructive, outline) provide good flexibility for various UI needs.

---

`26-34`: **Badge component implementation is clean and follows best practices.**

The Badge component is implemented as a function component with proper type definitions. The component correctly uses the `cn` utility for class name merging and applies the variant styles based on props.

</details>
<details>
<summary>src/components/LineChart.tsx (3)</summary>

`488-489`: **Type definition is clear and concise.**

The `LineStyle` type is well-defined with appropriate options for line styling needs.

---

`495-496`: **Props interface extension maintains backward compatibility.**

Adding the optional `lineStyles` prop as a Record mapping category names to line styles is a good approach that maintains backward compatibility with existing code.

---

`527-528`: **Default value for lineStyles prevents errors.**

Initializing lineStyles as an empty object ensures that even if the prop isn't provided, the code won't throw errors when accessing properties.

</details>
<details>
<summary>src/app/dashboard/_components/totals.tsx (2)</summary>

`3-8`: **Imports correctly updated for new functionality.**

The addition of the Badge component import and Arrow icons along with the getPrevDateRange function is appropriate for implementing the comparison functionality.

---

`54-77`: **UI implementation for displaying changes is consistent and well-designed.**

The Badge component is used consistently for displaying percentage changes with appropriate visual indicators (up/down arrows) and color-coding (green for positive, red for negative). The conditional rendering ensures badges only appear when valid comparison data exists.



Also applies to: 144-167, 226-249

</details>
<details>
<summary>src/components/ui-parts/PlaytimeChart.tsx (3)</summary>

`14-14`: **Good addition of optional `prevDailyPlaytime` prop.**  
This approach gracefully accommodates comparing previous and current data.

---

`44-46`: **Verify non-empty arrays for max calculation.**  
If either dataset is empty, `Math.max(...[])` can throw an error. Confirm there's always at least one entry in `combinedData`.

---

`55-58`: **Effective multi-series chart configuration.**  
Using distinct categories, colors, and line styles offers a clear visual distinction between periods. Nice work.  



Also applies to: 60-60, 67-71

</details>
<details>
<summary>src/app/dashboard/_components/daily-playtime-graph.tsx (8)</summary>

`7-7`: **Helpful import for `dateFormatter`.**  
This ensures consistent date formatting across queries and displays.

---

`10-10`: **Importing comparison utilities.**  
`getPrevDateRange` and `getTimeFilters` are essential for retrieving previous period data.

---

`33-34`: **Neat retrieval of previous date range and filters.**  
This lays the groundwork for side-by-side comparisons.

---

`40-40`: **Storing `prevDailyPlaytime`.**  
Good approach to keep the two datasets separate before passing them to the chart.

---

`44-44`: **Single-day view code comment updated.**  
No issues here—this comment clarifies the context for single-day breakdown.

---

`90-130`: **Implementation of previous period hourly breakdown.**  
Ensure you verify that `hoursSq` adequately covers the same hour range in both periods to avoid off-by-one or missing hours in shorter days (e.g., daylight savings transitions).

---

`131-132`: **Multi-day view logic for current period.**  
Clear separation of single vs. multi-day breakdown. Implementation looks straightforward.

---

`174-219`: **Previous period daily breakdown.**  
This mirror logic is consistent with the current period approach. Watch out for any mismatch in the number of dates if `prevDateRange` is shorter/longer than the main period.

</details>
<details>
<summary>src/app/dashboard/_components/top-tables.tsx (14)</summary>

`3-3`: **New imports for badges, tooltips, and ordinal.**  
Great choice to improve UI clarity with badges and descriptive tooltips.  



Also applies to: 14-19, 20-20

---

`29-29`: **Expanded imports for date range utilities and icons.**  
Everything is consistent with the rest of your data comparison logic.  



Also applies to: 31-31

---

`85-87`: **Fetching previous date range for TopArtists.**  
This sets up your current vs. previous period comparison.  



Also applies to: 88-88

---

`116-117`: **Query for previous period top artists.**  
Encapsulating the same constraints (userId, progressMs) ensures consistent comparisons.  



Also applies to: 118-134

---

`143-168`: **Rank and count change calculation for artists.**  
Handles absent artists by returning a `null` change. Be mindful of possible divisions by zero if `prevArtist.count` were ever 0.

---

`179-179`: **Artist table: rank and count change display with tooltips.**  
Tooltip logic is clear and concise, giving an immediate visual indication of changes. Nicely done.  



Also applies to: 181-207, 226-251

---

`270-271`: **Previous date range logic repeated for TopTracks.**  
Maintaining consistency in how you set up the filters is a good pattern.  



Also applies to: 272-273, 274-274

---

`300-301`: **Previous period top tracks query.**  
Same approach as top artists. Keep an eye on edge cases if `limit` truncates relevant tracks.  



Also applies to: 302-323

---

`324-349`: **Track rank and count diffs.**  
Similar logic to artists. Division by zero if `prevTrack.count` were unexpectedly 0 could cause issues.

---

`360-387`: **UI for displaying track comparisons.**  
Good usage of badges and icons to reflect direction and magnitude of change.  



Also applies to: 389-432

---

`452-453`: **Extending the same pattern to TopAlbums.**  
This cohesive approach for all entity types is well-structured and consistent.  



Also applies to: 454-454, 455-455

---

`481-482`: **Previous period top albums query.**  
As with artists/tracks, ensure large data sets do not hide significant rank changes if beyond the `limit`.  



Also applies to: 483-505

---

`506-530`: **Album rank changes.**  
Again, keep an eye out for zero counts or large discrepancies in data.

---

`542-542`: **Album table: rank and count changes with tooltip & badge.**  
Visually consistent with the rest of the top lists; great for user familiarity.  



Also applies to: 544-569, 589-614

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T19:19:04Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>src/components/percentage-badge.tsx (1)</summary><blockquote>

`4-32`: **Clean, well-structured component with good visual indicators!**

The component appropriately handles conditional rendering based on the percentage value, providing visual cues with both colors and icons to indicate changes. The implementation is concise and follows good React practices.



Some minor suggestions to consider:
- Consider adding an `aria-label` to the badge for better accessibility, describing what the percentage change represents
- You might want to handle extreme percentages (e.g., very large values) with additional formatting or capping


```diff
export function PercentageBadge({
    percentChange,
}: {
    percentChange: number | null;
}) {
    if (percentChange === null) return null;

+   // Format the percentage, capping extreme values for display
+   const displayValue = Math.abs(percentChange > 1000 ? 1000 : percentChange).toFixed(1);

    return (
        <Badge
            variant="outline"
            className={`flex items-center gap-1 px-1.5 py-0.5 text-xs ${
                percentChange > 0
                    ? "border-green-600/30 bg-green-600/10 text-green-600"
                    : percentChange < 0
                      ? "border-red-600/30 bg-red-600/10 text-red-600"
                      : "border-gray-500/30 bg-gray-500/10 text-gray-500"
            } `}
+           aria-label={`${percentChange > 0 ? 'Increased' : percentChange < 0 ? 'Decreased' : 'No change'} by ${Math.abs(percentChange).toFixed(1)}%`}
        >
            {percentChange > 0 ? (
                <ArrowUp className="h-3 w-3" />
            ) : percentChange < 0 ? (
                <ArrowDown className="h-3 w-3" />
            ) : (
                <Minus className="h-3 w-3" />
            )}
-           {Math.abs(percentChange).toFixed(1)}%
+           {displayValue}%
        </Badge>
    );
}
```

</blockquote></details>
<details>
<summary>src/app/dashboard/_components/top-tables.tsx (5)</summary><blockquote>

`116-167`: **Consider extracting comparison logic into a reusable function.**

The logic for calculating rank and count changes is duplicated across all three table components (artists, tracks, albums). This could be extracted into a reusable function to reduce duplication and make maintenance easier.



Here's a suggested implementation:

```typescript
function calculateComparisons<T extends { id: string }>(
    currentItems: Array<T & { count: number }>,
    previousItems: Array<T & { count: number }>,
    idKey: keyof T
) {
    return currentItems.map((item, currentIndex) => {
        // Find this item in previous period
        const prevItemIndex = previousItems.findIndex(
            (prevItem) => prevItem[idKey] === item[idKey]
        );

        // Determine rank change
        const rankChange =
            prevItemIndex !== -1 ? prevItemIndex - currentIndex : null;

        // Determine count change
        const prevItem =
            prevItemIndex !== -1 ? previousItems[prevItemIndex] : null;
        const countChange = prevItem
            ? ((item.count - prevItem.count) / prevItem.count) * 100
            : null;

        return {
            ...item,
            rankChange,
            countChange,
            previousRank: prevItemIndex !== -1 ? prevItemIndex + 1 : null,
        };
    });
}
```

Then you could use it like:
```typescript
const artistComparisons = calculateComparisons(
    topArtists,
    prevTopArtists,
    'artistId'
);
```

---

`184-206`: **Consider extracting tooltip content creation to a helper function.**

The tooltip content generation logic is duplicated across all three table components. Consider extracting this into a reusable helper function.



```typescript
function getRankChangeTooltip(
    rankChange: number | null,
    previousRank: number | null
): string {
    if (rankChange === null || previousRank === null) return "";
    
    if (rankChange > 0) {
        return `Moved up ${rankChange} rank${rankChange !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
    } else if (rankChange < 0) {
        return `Moved down ${Math.abs(rankChange)} rank${Math.abs(rankChange) !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
    } else {
        return "Same rank as previous period";
    }
}
```

Then in your JSX:
```jsx
<TooltipContent>
    {getRankChangeTooltip(item.rankChange, item.previousRank)}
</TooltipContent>
```

---

`253-330`: **Similar comparison logic could be reused.**

The track comparison logic is nearly identical to the artist comparison logic. This is a good candidate for the shared function suggested earlier.

---

`416-494`: **Album comparison logic should be refactored.**

The album comparison logic follows the same pattern as artists and tracks. This is another instance where the suggested reusable function would reduce code duplication.

---

`511-533`: **Tooltip accessibility could be improved.**

The tooltips provide good information about rank changes, but consider adding `aria-label` attributes to improve accessibility for screen readers.



```diff
<TooltipTrigger>
    {album.rankChange > 0 ? (
-       <ArrowUp className="h-4 w-4 text-green-600" />
+       <ArrowUp className="h-4 w-4 text-green-600" aria-label="Rank improved" />
    ) : album.rankChange < 0 ? (
-       <ArrowDown className="h-4 w-4 text-red-600" />
+       <ArrowDown className="h-4 w-4 text-red-600" aria-label="Rank decreased" />
    ) : (
-       <Minus className="h-4 w-4 text-gray-500" />
+       <Minus className="h-4 w-4 text-gray-500" aria-label="Rank unchanged" />
    )}
</TooltipTrigger>
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 3c40b16201419cb246c2adbb1fe4c7871a063009 and edd8511c5b3b5ec09b946cac599f9b80ec488808.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `src/app/dashboard/_components/top-tables.tsx` (15 hunks)
* `src/app/dashboard/_components/totals.tsx` (4 hunks)
* `src/components/percentage-badge.tsx` (1 hunks)

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>src/app/dashboard/_components/totals.tsx (4)</summary>

`9-33`: **Great extraction of common logic into a reusable function!**

The implementation of `getMetricsWithComparison` effectively addresses the code duplication issue noted in a previous review. The generic function is well-designed with clear types and a flexible approach that works for different metrics.




A minor improvement to consider:

```diff
async function getMetricsWithComparison<T>(
    userId: string,
    dateRange: DateRange,
    queryFn: (filters: SQL<unknown>, id: string) => Promise<T[]>,
    getValueFromResult: (result: T[]) => number,
): Promise<{
    current: number;
    previous: number;
    percentChange: number | null;
}> {
    const timeFilters = getTimeFilters(dateRange)!;
    const currentMetrics = await queryFn(timeFilters, userId);

    const prevDateRange = getPrevDateRange(dateRange);
    const prevTimeFilters = getTimeFilters(prevDateRange)!;
    const prevMetrics = await queryFn(prevTimeFilters, userId);

    const current = getValueFromResult(currentMetrics);
    const previous = getValueFromResult(prevMetrics);

    const percentChange =
-       previous > 0 ? ((current - previous) / previous) * 100 : null;
+       previous > 0 ? ((current - previous) / previous) * 100 : (previous === 0 && current > 0) ? 100 : null;

    return { current, previous, percentChange };
}
```

This would better handle the case where there were no items in the previous period but there are items in the current period, showing a 100% increase rather than null.

---

`44-61`: **Excellent refactoring of the TotalMinutes component!**

The component now uses the reusable `getMetricsWithComparison` function, which eliminates duplication and makes the code more maintainable.

---

`82-109`: **Good implementation of the TotalArtists component!**

The refactoring follows the same pattern as the TotalMinutes component, using the shared utility function to reduce code duplication.

---

`128-151`: **Nice implementation of the TotalTracks component!**

Similar to the other components, this has been effectively refactored to use the shared utility function.

</details>
<details>
<summary>src/app/dashboard/_components/top-tables.tsx (1)</summary>

`85-86`: **New comparison functionality looks good.**

The added code for getting the previous date range and time filters is well implemented.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T19:40:33Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>src/server/lib.ts (1)</summary><blockquote>

`167-207`: **Handle possible divide-by-zero scenario.**

If `prevItem[valueKey]` is zero, the percentage calculation could produce an infinite or NaN value. Consider adding a guard condition:


```diff
- ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) / Number(prevItem[valueKey])) * 100
+ ? (
+     Number(prevItem[valueKey]) !== 0
+         ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) / Number(prevItem[valueKey])) * 100
+         : 0
+ )
```
This prevents unexpected Infinity or NaN for the `percentChange`.

</blockquote></details>
<details>
<summary>src/app/leaderboard/page.tsx (2)</summary><blockquote>

`84-88`: **Timeframe filters for the current period.**

Logic for partial intervals is sensible, though it might be worth clarifying variable naming for readability.

---

`99-108`: **Previous period filters logic can be unified.**

You might consider reusing `getPrevDateRange` from `src/server/lib.ts` for clarity and consistency rather than manually deriving.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between edd8511c5b3b5ec09b946cac599f9b80ec488808 and 4cafd0bae3e96bcab2556b44e0cf559e706317f9.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `src/app/dashboard/_components/top-tables.tsx` (15 hunks)
* `src/app/leaderboard/page.tsx` (8 hunks)
* `src/server/lib.ts` (3 hunks)

</details>

<details>
<summary>🔇 Additional comments (27)</summary><blockquote>

<details>
<summary>src/server/lib.ts (2)</summary>

`37-45`: **Looks good overall.**

This helper function correctly computes the previous date range, mirroring the duration between `from` and `to`.

---

`209-222`: **Tooltip logic appears consistent.**

The rank change messaging and arrow display are clear and correct.

</details>
<details>
<summary>src/app/dashboard/_components/top-tables.tsx (16)</summary>

`3-3`: **Import of PercentageBadge is fine.**

---

`14-19`: **Tooltip imports are fine.**

---

`28-34`: **No issues with additional imports.**

---

`36-36`: **Icons import is fine.**

---

`90-91`: **Previous-date-range usage is correct.**

This systematically sets up a matching range for comparisons.

---

`122-147`: **Previous period top artists.**

The logic mirrors the current period query with new filters, which is consistent and clear.

---

`148-155`: **Comparison setup is straightforward.**

`calculateComparisons` is used properly with correct keys.

---

`166-219`: **Rendering rank and percentage changes.**

Using tooltips and icons for rank ups/downs is well-implemented and matches the rest.

---

`239-240`: **Same approach for tracks is consistent.**

---

`268-291`: **Previous period top tracks logic.**

Matches the pattern used in artists with correct ordering and grouping.

---

`292-299`: **Computation of track comparisons.**

No issues; the function keys are accurate.

---

`310-363`: **Track rendering mirrors artist logic.**

Rank arrows and tooltips again used consistently.

---

`383-384`: **Previous-date-range usage for albums is consistent.**

---

`412-436`: **Previous period albums.**

Neatly parallels the logic for current albums.

---

`437-444`: **Album comparison calculation.**

Uses the same approach with correct field names.

---

`455-508`: **Album rank display with tooltips and badges.**

Matches the style for artists and tracks, with consistent usage of arrow icons.

</details>
<details>
<summary>src/app/leaderboard/page.tsx (9)</summary>

`1-1`: **New percentage-badge import.**

No problems here; usage aligns well with the rest of the code.

---

`22-27`: **Tooltip imports are appropriate.**

---

`31-36`: **Imports for comparison logic and user-friends.**

No concerns; these are relevant to the new features.

---

`38-39`: **Icon imports are consistent.**

---

`116-120`: **Conditional progress filter for the previous period.**

Properly appended to maintain consistency with the current period.

---

`192-197`: **Ensuring metric is a number is a good safeguard.**

---

`198-226`: **Previous leaderboard query.**

The approach for building and grouping the prior results is well-structured.

---

`227-234`: **Comparison logic usage.**

Proper keys (`userId`, `metric`) are selected for rank and percentage computation.

---

`332-403`: **Arrow icons, tooltip usage, and rank display.**

Visually consistent with other modules. Handling rank change is clear and intuitive.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T20:33:49Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (15)</summary><blockquote>

<details>
<summary>src/lib/utils.ts (1)</summary><blockquote>

`54-68`: **Consider edge cases for negative or zero values.**

While this ordinal function is valid for positive integers, negative numbers or zero may produce unexpected suffixes (e.g., `0` lacking a suffix). If needed, handle these cases explicitly.

```diff
+// Example adjustment for zero and negative values
 export function ordinal(n: number) {
+  if (n <= 0) {
+    return `${n}th`; // or handle differently based on requirements
+  }
   const rule = englishOrdinalRules.select(n);
   return `${n}${suffixes[rule]}`;
 }
```

</blockquote></details>
<details>
<summary>src/app/api/update-now-playing/route.ts (2)</summary><blockquote>

`74-74`: **Efficient handling of user ID for playback retrieval.**

Calling `getUserPlaying(user.id)` directly streamlines user-specific Spotify data fetching. If the user list grows large, consider batching requests or using concurrency to handle potential rate limits.

---

`83-83`: **Enhance error visibility and reporting.**

While logging the error is helpful, consider integrating a more robust solution (e.g., a monitoring tool) to track repeated failures or alert on critical issues.

</blockquote></details>
<details>
<summary>src/components/ui-parts/PlaytimeChart.tsx (1)</summary><blockquote>

`60-60`: **Remove or implement the no-op callback**  
`onValueChange` is currently a trivial callback. Consider removing or wiring it up for additional functionality.  

```diff
- onValueChange={() => null}
+ // (Optional) Remove if not needed:
```

</blockquote></details>
<details>
<summary>src/components/LineChart.tsx (2)</summary><blockquote>

`495-495`: **Optional lineStyles property.**  
Consider adding inline documentation or a comment explaining how to use this property.

---

`803-942`: **Refactor or add tests for new line rendering logic.**  
The logic for per-category dot rendering and stroke styles is sound. However, consider adding unit or snapshot tests to cover the new functionality (especially dashed/dotted lines) and reduce reliance on “any” usage for Recharts props.

</blockquote></details>
<details>
<summary>src/server/lib.ts (1)</summary><blockquote>

`209-222`: **Rank change tooltip is concise.**  
Provides clear messaging for rank movement. Consider i18n if multi-language support is a future requirement.

</blockquote></details>
<details>
<summary>src/app/dashboard/_components/daily-playtime-graph.tsx (2)</summary><blockquote>

`90-130`: **Possible refactor to reduce duplication.**  
This block replicates much of the logic from the current period hourly breakdown. Consider extracting the logic for retrieving hourly playtime into a shared function to keep things DRY (Don’t Repeat Yourself).

---

`174-219`: **Similar duplication for the multi-day scenario.**  
This code mirrors the current period daily breakdown. Extract a common helper if possible to simplify updates and maintenance when the daily logic changes.

</blockquote></details>
<details>
<summary>src/app/leaderboard/page.tsx (3)</summary><blockquote>

`84-97`: **Consolidate timeframe-based filter logic.**  
The repeated `if/else` blocks effectively set `timeStart`. Consider extracting this into a helper function or a map/dictionary for scalability.

---

`99-109`: **Check correctness of previous period calculation.**  
`prevTimeSpan` and `prevTimeStart` logic ensures the previous window is identical in length. The arithmetic is correct but can be confusing at first glance. Consider clarifying with descriptive variable names or inline comments.

---

`198-219`: **Fetching previous leaderboard data.**  
Logic mirrors the current period approach. If further expansions are anticipated, consider a unified fetch function taking relevant filters to reduce duplication.

</blockquote></details>
<details>
<summary>src/app/dashboard/_components/top-tables.tsx (3)</summary><blockquote>

`121-146`: **Parallel query for the previous period (TopArtists).**  
The logic is nearly identical to the current query. Consider a shared function to avoid duplication.

---

`268-291`: **Refactor suggestion for repeated top items query.**  
The structure is duplicated across artists, tracks, and albums. A generic function to handle top items ranking could simplify maintenance.

---

`412-436`: **Previous period top albums.**  
As before, factor out if further expansions are planned.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 7f7037df40127a32542ed9954578ee7f8ee00700 and 4cafd0bae3e96bcab2556b44e0cf559e706317f9.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml`

</details>

<details>
<summary>📒 Files selected for processing (14)</summary>

* `package.json` (1 hunks)
* `src/app/api/update-now-playing/route.ts` (3 hunks)
* `src/app/dashboard/_components/daily-playtime-graph.tsx` (4 hunks)
* `src/app/dashboard/_components/top-tables.tsx` (15 hunks)
* `src/app/dashboard/_components/totals.tsx` (4 hunks)
* `src/app/leaderboard/page.tsx` (8 hunks)
* `src/components/LineChart.tsx` (4 hunks)
* `src/components/percentage-badge.tsx` (1 hunks)
* `src/components/ui-parts/PlaytimeChart.tsx` (2 hunks)
* `src/components/ui/badge.tsx` (1 hunks)
* `src/components/ui/tooltip.tsx` (1 hunks)
* `src/lib/utils.ts` (1 hunks)
* `src/server/lib.ts` (3 hunks)
* `src/server/spotify/spotify.ts` (1 hunks)

</details>

<details>
<summary>🔇 Additional comments (54)</summary><blockquote>

<details>
<summary>package.json (1)</summary>

`36-36`: <details>
<summary>✅ Verification successful</summary>

**Ensure compatibility and security for this new tooltip dependency.**

Consider verifying that `@radix-ui/react-tooltip@^1.1.8` is compatible with your current framework version and that there are no known vulnerabilities or breaking changes.

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Check if there are any known vulnerabilities or issues with @radix-ui/react-tooltip@^1.1.8

gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: NPM, package: "@radix-ui/react-tooltip") {
    nodes {
      advisory {
        summary
        severity
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'
```

Length of output: 336

---



</details>

**Tooltip dependency @radix-ui/react-tooltip is secure – please confirm framework compatibility**

We verified via the GitHub vulnerability scan that no known vulnerabilities affect `@radix-ui/react-tooltip@^1.1.8`. Please double-check that this version matches your current framework's peer dependency requirements and review the release notes for any potential breaking changes.

</details>
<details>
<summary>src/server/spotify/spotify.ts (1)</summary>

`4-4`: **Server-only usage confirmed.**

Including `"server-only"` ensures your Spotify-related code remains strictly server-side, preventing accidental client-side bundling. This approach is aligned with Next.js best practices.

</details>
<details>
<summary>src/app/api/update-now-playing/route.ts (1)</summary>

`7-7`: **New import aligns with the simplified token logic.**

Replacing the previous token-based approach with a direct call to `getUserPlaying` improves maintainability. Ensure all references to `getSpotifyToken` are removed throughout the codebase.

</details>
<details>
<summary>src/components/percentage-badge.tsx (1)</summary>

`1-33`: **Well-structured percentage change display**  
The component cleanly handles the `null` scenario by returning `null` early and offers intuitive color-coding for positive, negative, and neutral changes. Tailwind classes and icon usage are straightforward, and displaying one decimal place is a thoughtful detail.

</details>
<details>
<summary>src/components/ui/tooltip.tsx (1)</summary>

`1-33`: **Clean and accessible tooltip implementation**  
Using Radix UI’s portal and `forwardRef` ensures accessibility and proper rendering. The animation classes and sideOffset logic are well-organized. No issues observed.

</details>
<details>
<summary>src/components/ui/badge.tsx (1)</summary>

`1-37`: **Robust badge variants**  
This code demonstrates a proper usage of class-variance-authority, cleanly defining multiple styling variants and a default variant. The `Badge` component approach is succinct and maintainable.

</details>
<details>
<summary>src/components/ui-parts/PlaytimeChart.tsx (8)</summary>

`14-15`: **Optional prop for previous data**  
Making `prevDailyPlaytime` optional preserves backward compatibility, ensuring existing calls remain valid.

---

`17-19`: **Early return for single data set**  
Returning a simple chart when `prevDailyPlaytime` is absent is a clear approach that helps keep the code easily readable.

---

`20-29`: **Fallback single-line chart**  
Configuring the `LineChart` for the current data only is consistent. Confirm that UI feedback is distinct enough when displaying a single period.

---

`32-41`: **Merging current and previous data**  
The inline map creates a clean combined dataset. Using a fallback for `prevItem` avoids index errors if arrays differ, though it may affect comparative accuracy.  

Would you like a shell script to confirm array length consistency across all usage sites?

---

`44-46`: **Calculating maximum playtime**  
`Math.max` usage is straightforward. This logic will help ensure the chart accommodates both datasets.

---

`55-55`: **Propagating combinedData**  
Assigning `combinedData` to the chart is a logical next step to render both periods at once.

---

`57-58`: **Descriptive category labels**  
Using “Current Period” and “Previous Period” clarifies the data, improving user comprehension.

---

`67-71`: **Distinct line styles**  
Solid vs. dashed lines effectively differentiate the two time periods, enhancing chart readability.

</details>
<details>
<summary>src/components/LineChart.tsx (2)</summary>

`488-488`: **New line style type looks good.**  
Introduces a well-defined union of possible line styles, enhancing type safety.

---

`527-527`: **Defaulting lineStyles to an empty object is a safe approach.**  
Presetting to `{}` prevents runtime errors when accessing sub-keys.

</details>
<details>
<summary>src/app/dashboard/_components/totals.tsx (6)</summary>

`3-3`: **Imported PercentageBadge successfully.**  
This indicates an improved UI for highlighting metric changes.

---

`6-7`: **Extended imports for date range comparison.**  
Bringing in `getPrevDateRange` and `SQL` usage is consistent with your new comparison approach.

---

`9-33`: **Centralized comparison logic is an excellent DRY improvement.**  
The `getMetricsWithComparison` function mirrors the suggestion from previous reviews to remove duplication and handle potential zero division gracefully.

---

`44-61`: **TotalMinutes now uses getMetricsWithComparison.**  
Refactoring clarifies the code and prevents repeated logic. Good job.

---

`82-109`: **TotalArtists also utilizes the shared comparison function.**  
Nicely done. This ensures consistent calculation logic across metrics.

---

`128-151`: **TotalTracks comparison refactor.**  
Aligns well with the existing pattern. The code is easier to maintain and extend.

</details>
<details>
<summary>src/server/lib.ts (2)</summary>

`1-1`: **Ordinal import is suitable for rank-based string formatting.**  
No issues with referencing `ordinal` from `@/lib/utils`.

---

`37-45`: **Previous date range calculation.**  
The new function correctly preserves the same duration interval, preventing date alignment errors.

</details>
<details>
<summary>src/app/dashboard/_components/daily-playtime-graph.tsx (6)</summary>

`7-7`: **No issues with this added import.**  
The `dateFormatter` import is used appropriately below for generating date sequences.

---

`10-10`: **Good practice to keep related utilities together.**  
Importing both `getPrevDateRange` and `getTimeFilters` from the same module is concise and makes the code more maintainable.

---

`33-34`: **Handle edge cases for single-day vs. multi-day usage.**  
The initialization of `prevDateRange` and `prevTimeFilters` is straightforward. Consider verifying the behavior when start/end dates are the same, though the single-day view check likely covers that.

---

`40-40`: **Declaration is consistent.**  
Declaring `prevDailyPlaytime` in parallel to `dailyPlaytime` clarifies usage for previous periods.

---

`131-131`: **No functional changes.**  
Just a comment line clarifying the multi-day scenario. Nothing to address here.

---

`221-226`: **Verify handling of empty previous data.**  
Returning both `dailyPlaytime` and `prevDailyPlaytime` is sound. Ensure the `PlaytimeChart` gracefully handles an empty or undefined `prevDailyPlaytime`.

</details>
<details>
<summary>src/app/leaderboard/page.tsx (10)</summary>

`1-1`: **The added PercentageBadge import is appropriate.**  
Bringing in the badge component helps highlight comparative percentage changes conveniently.

---

`22-27`: **UI enhancements via Tooltip imports.**  
Tooltips improve UX for rank change details, keeping the interface clean yet informative.

---

`31-36`: **Centralizing key functionalities from `server/lib`.**  
Grouping `calculateComparisons`, `getBaseUrl`, `getRankChangeTooltip`, and `getUserFriends` is helpful for code discoverability.

---

`38-39`: **Icon imports align with rank visuals.**  
Using Lucide icons for up/down or neutral changes is consistent with standard design patterns.

---

`116-120`: **Excluding short plays from counts.**  
This differentiates “playtime” from “count”-based metrics. The approach is valid; calls to the same logic for `prevFilters` keep consistency.

---

`173-173`: **No functional change here.**  
This new comment merely states the purpose of the code block for clarity.

---

`192-197`: **Good fix for typed metrics.**  
Casting `user.metric` to a number ensures consistent usage and avoids subtle type issues.

---

`221-225`: **Consistency with type coercion.**  
Same numeric cast as the current period. Straightforward and consistent.

---

`227-234`: **Calculating user comparisons.**  
Leverages a robust approach for rank and metric changes. Verify that `calculateComparisons` handles empty results gracefully.

---

`332-366`: **Intuitive rank change rendering logic.**  
Using icons and tooltips for rank changes is a user-friendly approach. The color scheme (green for improvements, red for drops) is standard.  



Also applies to: 389-402

</details>
<details>
<summary>src/app/dashboard/_components/top-tables.tsx (14)</summary>

`3-3`: **PercentageBadge import underlines comparison context.**  
This aligns well with the new comparative features introduced in multiple dashboard components.

---

`14-19`: **Helpful tooltip-driven UI.**  
Adding the `TooltipProvider` and supporting components nicely clarifies rank changes without cluttering the view.

---

`28-34`: **Centralized date range and comparison utilities.**  
Importing these from a single location keeps the top-level code simpler and easier to maintain.

---

`36-36`: **Icons for indicating rank changes.**  
These icons match the pattern in the leaderboard changes, promoting UI consistency.

---

`90-92`: **Previous period filters for TopArtists.**  
Using `getPrevDateRange` and `getTimeFilters` ensures consistency with the approach used in other components.

---

`93-93`: **Comment clarifying current period retrieval.**  
This clarifies that the following block is for the current period data.

---

`148-155`: **Comparison logic for artists.**  
`calculateComparisons` is used effectively here to detect rank and count deltas.

---

`166-219`: **Clear rank change visuals for artists.**  
Tooltips, icons, and `PercentageBadge` unify the comparative data into a concise UI. Good consistency with the rest of the codebase.

---

`239-241`: **Previous period filters for TopTracks.**  
Again, consistent approach for obtaining previous time data.

---

`292-299`: **Track-by-track comparison.**  
This effectively reuses the universal comparison logic.

---

`310-363`: **Similar rank change UI for tracks.**  
The tooltips and arrow icons maintain the established design pattern.

---

`383-385`: **Reusing the date range logic for TopAlbums.**  
Consistent approach ensures a uniform user experience across all “Top” tables.

---

`438-444`: **Album comparison logic.**  
Makes the code cohesive with the existing approach for tracks and artists.

---

`455-508`: **Consistent rank visuals in TopAlbums.**  
Tooltips and arrow icons match the rest of the dashboard.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T20:45:00Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>src/server/lib.ts (1)</summary><blockquote>

`211-224`: **Good implementation of rank change tooltip formatter.**

The function provides clear, human-readable descriptions of rank changes including proper handling of pluralization and ordinal formatting. This will enhance the user experience when viewing comparison data.

Consider using template literals for improved readability:




```diff
-        return `Moved up ${rankChange} rank${rankChange !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
+        return `Moved up ${rankChange} rank${rankChange !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
-        return `Moved down ${Math.abs(rankChange)} rank${Math.abs(rankChange) !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
+        return `Moved down ${Math.abs(rankChange)} rank${Math.abs(rankChange) !== 1 ? "s" : ""} from ${ordinal(previousRank)}`;
```

Oh, I see you're already using template literals! That's great. The code is fine as is.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 4cafd0bae3e96bcab2556b44e0cf559e706317f9 and f4315161e2b275a6e4fc34e64b1f43b4a1815cf8.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `src/server/lib.ts` (3 hunks)

</details>

<details>
<summary>🔇 Additional comments (2)</summary><blockquote>

<details>
<summary>src/server/lib.ts (2)</summary>

`37-45`: **Well-designed function for calculating previous date ranges.**

This function correctly calculates a previous time period of equal duration to the current one, which aligns well with the PR objective of implementing comparisons to previous periods.

---

`167-209`: **Consider improving type safety and edge case handling.**

This function does a good job of calculating comparisons between current and previous data sets, but there are a few areas that could be improved:

1. The `@ts-expect-error` comment indicates a type safety issue. It would be better to resolve this rather than suppressing it.
2. The handling of zero values in the previous period could be improved.



```diff
                ? Number(prevItem[valueKey]) !== 0
                    ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) /
                          Number(prevItem[valueKey])) *
                      100
                    : 0
                : null;
```

3. Adding an explicit return type would improve code maintainability:

```diff
export function calculateComparisons<
    TCurrent extends Record<string, unknown>,
    TPrev extends Record<string, unknown>,
    TValueKey extends string | number | symbol,
>(
    currentItems: Array<TCurrent>,
    previousItems: Array<TPrev>,
    idKey: keyof TCurrent & keyof TPrev,
    valueKey: TValueKey & keyof TCurrent & keyof TPrev,
-) {
+): Array<TCurrent & { 
+    rankChange: number | null;
+    percentChange: number | null;
+    previousRank: number | null;
+}> {
```

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T20:50:00Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>src/app/feed/recent-listens.tsx (3)</summary><blockquote>

`124-156`: **Consider reducing duplication in tooltip implementations.**

There's notable code duplication between the mobile and desktop tooltip implementations. The only difference is in the container paragraph's CSS classes.


Consider extracting a shared component or implementing conditional classes:

```diff
- <TooltipProvider>
-     <Tooltip>
-         <TooltipTrigger asChild>
-             <p className="cursor-pointer text-xs text-muted-foreground xs:hidden">
-                 {timeAgo(
-                     new Date(listen.timestamp),
-                 )}
-             </p>
-         </TooltipTrigger>
-         <TooltipContent>
-             {formatFullTimestamp(
-                 new Date(listen.timestamp),
-             )}
-         </TooltipContent>
-     </Tooltip>
- </TooltipProvider>
- 
- <TooltipProvider>
-     <Tooltip>
-         <TooltipTrigger asChild>
-             <p className="hidden cursor-pointer whitespace-nowrap text-xs text-muted-foreground xs:block">
-                 {timeAgo(
-                     new Date(listen.timestamp),
-                 )}
-             </p>
-         </TooltipTrigger>
-         <TooltipContent>
-             {formatFullTimestamp(
-                 new Date(listen.timestamp),
-             )}
-         </TooltipContent>
-     </Tooltip>
- </TooltipProvider>

+ <TooltipProvider>
+     <Tooltip>
+         <TooltipTrigger asChild>
+             <p className={`cursor-pointer text-xs text-muted-foreground ${
+                 "xs:hidden"
+             }`}>
+                 {timeAgo(
+                     new Date(listen.timestamp),
+                 )}
+             </p>
+         </TooltipTrigger>
+         <TooltipContent>
+             {formatFullTimestamp(
+                 new Date(listen.timestamp),
+             )}
+         </TooltipContent>
+     </Tooltip>
+ </TooltipProvider>
+ 
+ <TooltipProvider>
+     <Tooltip>
+         <TooltipTrigger asChild>
+             <p className="hidden cursor-pointer whitespace-nowrap text-xs text-muted-foreground xs:block">
+                 {timeAgo(
+                     new Date(listen.timestamp),
+                 )}
+             </p>
+         </TooltipTrigger>
+         <TooltipContent>
+             {formatFullTimestamp(
+                 new Date(listen.timestamp),
+             )}
+         </TooltipContent>
+     </Tooltip>
+ </TooltipProvider>
```

Or create a reusable timestamp component:

```tsx
const TimestampWithTooltip = ({ 
  timestamp, 
  className 
}: { 
  timestamp: string | Date, 
  className: string 
}) => (
  <TooltipProvider>
    <Tooltip>
      <TooltipTrigger asChild>
        <p className={`cursor-pointer text-xs text-muted-foreground ${className}`}>
          {timeAgo(new Date(timestamp))}
        </p>
      </TooltipTrigger>
      <TooltipContent>
        {formatFullTimestamp(new Date(timestamp))}
      </TooltipContent>
    </Tooltip>
  </TooltipProvider>
);

// Usage:
<TimestampWithTooltip 
  timestamp={listen.timestamp} 
  className="xs:hidden" 
/>
<TimestampWithTooltip 
  timestamp={listen.timestamp} 
  className="hidden whitespace-nowrap xs:block" 
/>
```

---

`49-60`: **Consider moving `formatFullTimestamp` to a utility file.**

The `formatFullTimestamp` function is a generic utility for formatting dates and could be reused in other components. Consider moving it to a shared utility file (e.g., `src/lib/utils.ts`) alongside the existing `timeAgo` function.


```diff
// In src/lib/utils.ts
+ export function formatFullTimestamp(date: Date) {
+   return date.toLocaleString(undefined, {
+     weekday: "long",
+     year: "numeric",
+     month: "long",
+     day: "numeric",
+     hour: "2-digit",
+     minute: "2-digit",
+     second: "2-digit",
+   });
+ }

// In this file
import { timeAgo, formatFullTimestamp } from "@/lib/utils";
// Then remove the local function definition
```

---

`125-138`: **Consider caching date objects to improve performance.**

Creating new Date objects and formatting them may impact performance, especially when rendering many items. Consider optimizing by creating the Date object once per listen item.


```diff
{listens.map((listen) => {
+   const listenDate = new Date(listen.timestamp);
    return (
        <div key={listen.id} className="flex gap-4 sm:items-center">
            {/* other content */}
            <TooltipProvider>
                <Tooltip>
                    <TooltipTrigger asChild>
                        <p className="cursor-pointer text-xs text-muted-foreground xs:hidden">
-                           {timeAgo(new Date(listen.timestamp))}
+                           {timeAgo(listenDate)}
                        </p>
                    </TooltipTrigger>
                    <TooltipContent>
-                       {formatFullTimestamp(new Date(listen.timestamp))}
+                       {formatFullTimestamp(listenDate)}
                    </TooltipContent>
                </Tooltip>
            </TooltipProvider>
            
            {/* Same change for the other tooltip */}
        </div>
    );
})}
```


Also applies to: 142-155

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f4315161e2b275a6e4fc34e64b1f43b4a1815cf8 and 6c9bee94e9585ae8a044304d4f78d65ea568bf63.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/app/dashboard/_components/top-tables.tsx` (15 hunks)
* `src/app/feed/recent-listens.tsx` (3 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/app/dashboard/_components/top-tables.tsx

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>src/app/feed/recent-listens.tsx (4)</summary>

`5-10`: **Well-structured imports added for tooltip functionality.**

The tooltip component imports are well-organized and properly structured according to the project's conventions.

---

`49-60`: **Clean utility function for timestamp formatting.**

The `formatFullTimestamp` function is well-implemented, providing a consistent and detailed timestamp format for the tooltips. Using `toLocaleString` with detailed options ensures a user-friendly display of the full date and time.

---

`124-139`: **Tooltip implementation for mobile view enhances user experience.**

The tooltip integration for mobile view is well-implemented, providing users with additional context about when a track was played. Making the timestamp interactive by adding the cursor-pointer style is a nice touch.

---

`141-156`: **Tooltip implementation for desktop view maintains consistency.**

The desktop view tooltip implementation maintains a consistent user experience across different viewport sizes. The use of responsive classes ensures proper display on different devices.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-04T21:07:04Z

Review comments:
diff_hunk: @@ -28,10 +30,50 @@ export async function TotalMinutes({
         totalMinutes = totalProgressMs[0]!.duration / 60000;
     }
 
+    // Get the previous date range
+    const prevDateRange = getPrevDateRange(dateRange);
+    const prevTimeFilters = getTimeFilters(prevDateRange);
+
+    const prevTotalProgressMs = await db
+        .select({
+            duration: sql<number>`sum(${listeningHistory.progressMs})`,
+        })
+        .from(listeningHistory)
+        .where(and(eq(listeningHistory.userId, userId), prevTimeFilters));
+
+    let prevTotalMinutes = 0;
+    if (prevTotalProgressMs) {
+        prevTotalMinutes = prevTotalProgressMs[0]!.duration / 60000;
+    }
+
+    const percentChange =
+        prevTotalMinutes > 0
+            ? ((totalMinutes - prevTotalMinutes) / prevTotalMinutes) * 100
+            : null;
+
path: src/app/dashboard/_components/totals.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Significant code duplication in calculating previous period metrics.**

There's substantial duplication in calculating metrics for previous periods across all three components (TotalMinutes, TotalArtists, TotalTracks). Consider refactoring this pattern into a reusable function to improve maintainability.

Create a shared utility function to calculate both current and previous metrics:

```typescript
async function getMetricsWithComparison<T>(
  userId: string,
  dateRange: DateRange,
  queryFn: (filters: SQL<unknown>, id: string) => Promise<T[]>
): Promise<{ current: number, previous: number, percentChange: number | null }> {
  const timeFilters = getTimeFilters(dateRange);
  const currentMetrics = await queryFn(timeFilters, userId);
  
  const prevDateRange = getPrevDateRange(dateRange);
  const prevTimeFilters = getTimeFilters(prevDateRange);
  const prevMetrics = await queryFn(prevTimeFilters, userId);
  
  const current = getValueFromResult(currentMetrics);
  const previous = getValueFromResult(prevMetrics);
  
  const percentChange = 
    previous > 0 ? ((current - previous) / previous) * 100 : null;
    
  return { current, previous, percentChange };
}
```

Then each component could use this shared function with its specific query.


Also applies to: 113-142, 199-224

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-04T19:19:03Z
author_association: NONE
diff_hunk: @@ -152,3 +163,60 @@ export async function getUserFriends(userId: string) {
 
     return friends.map((friend) => friend.friendId);
 }
+
+export function calculateComparisons<
+    TCurrent extends Record<string, unknown>,
+    TPrev extends Record<string, unknown>,
+    TValueKey extends string | number | symbol,
+>(
+    currentItems: Array<TCurrent>,
+    previousItems: Array<TPrev>,
+    idKey: keyof TCurrent & keyof TPrev,
+    valueKey: TValueKey & keyof TCurrent & keyof TPrev,
+) {
+    return currentItems.map((item, currentIndex) => {
+        // Find this item in previous period
+        const prevItemIndex = previousItems.findIndex(
+            // @ts-expect-error Based on usage, this is fine
+            (prevItem) => prevItem[idKey] === item[idKey],
+        );
+
+        // Determine rank change
+        const rankChange =
+            prevItemIndex !== -1 ? prevItemIndex - currentIndex : null;
+
+        // Determine value change
+        const prevItem =
+            prevItemIndex !== -1 ? previousItems[prevItemIndex] : null;
+        const valueChange =
+            prevItem &&
+            typeof item[valueKey] === "number" &&
+            typeof prevItem[valueKey] === "number"
+                ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) /
+                      Number(prevItem[valueKey])) *
+                  100
+                : null;
+
+        return {
+            ...item, // This preserves all properties from the current item
+            rankChange,
+            percentChange: valueChange,
+            previousRank: prevItemIndex !== -1 ? prevItemIndex + 1 : null,
+        };
+    });
+}
path: src/server/lib.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Handle potential divide-by-zero cases.**  
When computing `valueChange`, zero or negative values in previous items can result in `Infinity` or `NaN`. Consider applying the same defensive check used in `getMetricsWithComparison` to ensure safe comparisons.  

```diff
-                ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) / Number(prevItem[valueKey])) * 100
+                ? Number(prevItem[valueKey]) > 0
+                    ? ((Number(item[valueKey]) - Number(prevItem[valueKey]))
+                       / Number(prevItem[valueKey])) * 100
+                    : null
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
export function calculateComparisons<
    TCurrent extends Record<string, unknown>,
    TPrev extends Record<string, unknown>,
    TValueKey extends string | number | symbol,
>(
    currentItems: Array<TCurrent>,
    previousItems: Array<TPrev>,
    idKey: keyof TCurrent & keyof TPrev,
    valueKey: TValueKey & keyof TCurrent & keyof TPrev,
) {
    return currentItems.map((item, currentIndex) => {
        // Find this item in previous period
        const prevItemIndex = previousItems.findIndex(
            // @ts-expect-error Based on usage, this is fine
            (prevItem) => prevItem[idKey] === item[idKey],
        );

        // Determine rank change
        const rankChange =
            prevItemIndex !== -1 ? prevItemIndex - currentIndex : null;

        // Determine value change
        const prevItem =
            prevItemIndex !== -1 ? previousItems[prevItemIndex] : null;
        const valueChange =
            prevItem &&
            typeof item[valueKey] === "number" &&
            typeof prevItem[valueKey] === "number"
                ? Number(prevItem[valueKey]) > 0
                    ? ((Number(item[valueKey]) - Number(prevItem[valueKey])) /
                          Number(prevItem[valueKey])) *
                      100
                    : null
                : null;

        return {
            ...item, // This preserves all properties from the current item
            rankChange,
            percentChange: valueChange,
            previousRank: prevItemIndex !== -1 ? prevItemIndex + 1 : null,
        };
    });
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-04T20:44:59Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-04T19:15:42Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
This update introduces several enhancements and new features. A new dependency for tooltips has been added, and the Spotify API flow has been simplified by removing token handling. Multiple dashboard components now fetch and compare data between current and previous periods, incorporating new SQL queries, additional props, and UI elements such as badges and tooltips. The chart components have been enhanced with configurable line styles and multi-period data rendering. Additionally, new utility functions have been introduced for ordinal formatting and date range calculations, and new UI components for badges and tooltips have been implemented.

## Changes

| File(s)                                                                                   | Change Summary                                                                                                                                                                                                                                                                                         |
|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `package.json`                                                                            | Added dependency `@radix-ui/react-tooltip` (version `^1.1.8`).                                                                                                                                                                                                                                        |
| `src/app/api/update-now-playing/route.ts` <br> `src/server/spotify/spotify.ts`            | Removed `getSpotifyToken` and its error handling from the update route; now calls `getUserPlaying(user.id)`. Added a `"server-only"` import in `spotify.ts` to indicate server-only usage.                                                                                                      |
| `src/app/dashboard/.../daily-playtime-graph.tsx` <br> `src/app/dashboard/.../top-tables.tsx` <br> `src/app/dashboard/.../totals.tsx` | Enhanced dashboard components to fetch and display previous period data alongside current data. New queries and variables (using `getPrevDateRange` and `getTimeFilters`) have been added, and UI elements such as badges and tooltips now reflect comparative metrics between periods. |
| `src/components/LineChart.tsx` <br> `src/components/ui-parts/PlaytimeChart.tsx`           | Introduced a new `LineStyle` type and an optional `lineStyles` prop to support different dash patterns. The PlaytimeChart now merges and renders data for both current and previous periods with distinct visual styles.                                                                       |
| `src/components/ui/badge.tsx` <br> `src/components/ui/tooltip.tsx`                        | Added new UI components: a Badge component (using `class-variance-authority`) and a Tooltip component (using Radix UI), including supporting exports for tooltip functionality.                                                                                                                 |
| `src/lib/utils.ts` <br> `src/server/lib.ts`                                               | Added a new `ordinal(n: number)` function for generating ordinal suffixes, and a new `getPrevDateRange` function to calculate previous date ranges.                                                                                                                                                 |

## Sequence Diagram(s)
```mermaid
sequenceDiagram
    participant C as Client
    participant A as /api/update-now-playing Route
    participant S as Spotify Service
    C->>A: Request currently playing track
    A->>S: Call getUserPlaying(user.id)
    S-->>A: Return playing track data
    A-->>C: Respond with track data
```

```mermaid
sequenceDiagram
    participant U as User Dashboard
    participant D as Dashboard Component
    participant DB as Database
    participant PC as PlaytimeChart
    participant LC as LineChart
    U->>D: Request playtime data
    D->>DB: Query current & previous period data (via getPrevDateRange & getTimeFilters)
    DB-->>D: Return aggregated data
    D->>PC: Pass combined data & lineStyles prop
    PC->>LC: Render multi-line chart with current & previous data
    LC-->>PC: Chart rendered
```

## Poem
> I'm a rabbit hopping through code so neat,  
> Where tooltips and badges make the UI complete.  
> With past and present data dancing side by side,  
> And stylish lines on charts taking a joyful ride.  
> In each commit, a playful leap, so bright and true—  
> Celebrating these changes with a bunny "yo-hoo!"

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6c9bee94e9585ae8a044304d4f78d65ea568bf63 and b4ae2fc7dd42852c90e54c298df617fc6a217b65.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `src/app/feed/recent-listens.tsx` (3 hunks)
* `src/lib/utils.ts` (2 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* src/app/feed/recent-listens.tsx

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/lib/utils.ts (3)</summary>

`29-39`: **Well-implemented timestamp formatting function.**

This new utility function provides comprehensive date formatting with clear parameters. The implementation uses standard `toLocaleString` with appropriate options for displaying a detailed human-readable timestamp.

---

`66-75`: **Good internationalization approach for ordinal suffixes.**

Using `Intl.PluralRules` is a robust approach for handling ordinal suffixes. The implementation correctly maps all possible plural categories returned by the English ordinal rules.


Note: The `zero` and `many` categories are included even though they're not typically used in English ordinals. This is fine and could be useful if extending to other languages in the future.

---

`77-80`: **Clean and effective ordinal function implementation.**

This function elegantly combines the plural rule selection with the suffix mapping to produce properly formatted ordinal numbers.

</details>

</blockquote></details>

</details>
<!-- internal state start -->


<!-- DwQgtGAEAqAWCWBnSTIEMB26CuAXA9mAOYCmGJATmriQCaQDG+Ats2bgFyQAOFk+AIwBWJBrngA3EsgEBPRvlqU0AgfFwA6NPEgQAfACgjoCEYDEZyAAUASpADK2VmgqyjcEj2wAbb5AokAI7Y0riQ4rjedJAARACCtPRMzNwuSPgYyAQ8ARLw+NjI3JT5tDGQAO5oyIjYAszqNPRy4bCehZSQAMIAIgCiAJwADOgY9KjwShjiAGbw0dWQGE4CnQCMAOwaMG2QzNpYM/gMhfwzrai2KFn4KClRbNMKKWmIGZAzJNTYAchHfMluBl2Mh3rhdrRqrABPgXPRUqQzq1PLx8CIxNsErR1PkMGhfLIADTIyAdPgYfA0LK7eDTdi48K3EgADxoY2RiE8gNe70+31+jJJUTQSgoMLhPDQiNpHzwP3a3EhVI07gh0gYFHg3HE73xb0gCCIsG88CNuCyFVuiGKDHgcwYd1RUke5q4ZFgmFtGCIQth2O9kEQuGonn+Qq+ovFFHhUs8mHoIv9PoI+G84m4BvwUj4M2wGDEuPx6nkYfEbGQ2Ot3jQsmiYN2n2iCJI2w8nMg7s9JBdyG0zEF8BSFCz7U5fBZxU1ZAYcfZtJoFBmaBnKDpVAL7wq6gQWHBce43BNDGouJVBgAYvgcz893wlMH4N5EMS915fP4giEg4xMJBVugGBnRBOQTMJXyOXx8C3AMAFUbAAGS4ABtWBcFwbhEA4AB6LCiG3OoNGSLDekGIYsIqEgBDAa1KTtWQwHBWkiCw7gfG8LDNgAXQAClQ9DMJwvDwQIoiSOGcjKOooFZnoxjvRYtiOI2ABKM8LG6FgewcJx9lcVUuU0kF+F3XZWPfAJglCVcGG8bAlFXBdlx1TIckHNJvHkVZcAosgSTJbp+mGUZ6FfNA8BYENmkpSAAAMmFFFQ1AfGLtjEkYLK/Jp0EDHSXHkeNstzcySDyEgKg+Yd+1fGFcGJCoEAYWBKkoTwaqasKCGIMhlCy34gUyaRV2xY9xADcFqAA5zkAogIeEof42FoM80v8bQQOyph80oLAAiIOEmJJaT6XxObFyvfYtsdJykVfchysazBSGfQMbTteBjwJPZ6QyA7xrA3YrnnYdaGwID0ESHEMhOpUVGqTxLKnUEsG8P1fohKEowTdlhUjWFo0lJ7WzaeQKSyv7A1pFc93bQFgWmXtZrHbNnn6oyCrwR94AAL08WprTIdsYTzSFEY+K9A1wAI0Aab0X2J5rZrzWpZrtC5poKbx6EQU0MDe49pg89AZk+MRToWrszwASR26R+s5Yk4sUZRVHUbQYpyLNJgWLBMHxWRtdBc5X0nc3LsHQ93pPDJA5JB7vWkInWuiza5goRbWgm19AbGEhoiBxRQcGxNIbxPwYYEOHIAR+ZnpIY3RHEKRDdoAoBBNMbdmWZhVj4fBzmrwawy+Rqvsl97+D4PJaiLLmo6wetPEhRBoTxrH6BxyhMYJhPIAACSgkrKGJTlsxOpcxCvaaWoNU1jVvsmPTCJgfHoBpxD2mhDvm86uyusQOCMGsbYAAqYB9hKDMy6CwVm9NQFcA8ODbEzkToD2QBQPMxkSRMyPnsWkg5uYHRsvMaY1EvZmx/mHYCX4VQACYQFgMKMUAakAABCBQxhpGkHAnY0g4yQXKmGTabIwK3H/EGKW6dFjqD/KIFgRcJDaGrG3EgxJw7DjyAGfyE4SjTlavISsh4awHVSBQc0N1TKxkqAgKI/BbzIBRiKFUABmehVgXD4iiH4AAiiERG3CEGoLFnwV8JwKABCeAVXgJV8inEnKUZA+t/AYM2qE8JkQiQfhBl6H0I4qDvmrGyBgsgVQABZ6E9GoLDdsfRjbvWIUU/xuxAkuE8BRXw1FJag1wPKWgdUGpNTaN4DCewDgPiwHXe09Tin6T/NFPUtwgTzjrHgdAUsbh7B+gQCgx9QbtRqBAzoDjenhEHJ4bIqdvxyBoMSSJlJjp+E2uQDcyN8FmJBpqAMhomqSzQLUhg2xLZPwyDZOy0Q/rEjwlIEycYKCNXUA3eUcsUTfzTr/cO11l4a3oKTGRFMiC63tJgWqf4Vm1CIE9LK+BtT4NnlNSAJoADWXJlw7h9FEvIBQiglEUB+WoaZQR8ArrgOFAZAmqxDhQlcSBahF01O2GUqIsnOTPAAeVPr4JFzwtKcmeR+GYUQxC9jFicdalYTXa3eAvchqKw73EjnSvuh1hxAnWnHJ6dVtxLFuMXZBfhGy0ArgwBlE9VzazNH8Sqti2h8DJIgM8+hjDgCgGQegjqOqEFIOQKgWVkgui4LwfgwgG6SEGi0eKzskpaB0AmkwUA4ATF7D7cKXUs2RS1ewLgVByq1GcK4P88hy1UBdpobQugwCGETaYAwqQg2xg0EIN4GAAExBXQYdScRLYtp6tEHtul5COrddIIwcQlhlUgEoJhUwikOwAAJUGxMyMA2B4BYSlmIBi+BUzphisSD0Mhc4+0SGC24r4YoXpTdOGu7sdXOXMZ4GKM6GVzoXRkd2cwbFbnBCSbMFqsD81tHMBYyAYoAD0gFAIABwpR2KgH1DIPFQWpCiYc6IRGknECaHmjIv1ahZnTMxMxI2vhsCKeAzJIAwUtvS+AAgqCuG2AAOVuOBnO+Ya4K08AEZgI5U18G09iQj4wTKoAMzMNSlg4hph6ridZr4lA2XcQ684E4ryUr4KxNu496TiCPQYKAPQSCXunPlIDtAuAxRiHe0Tj7n2vuHrgD9PHuAxC4DEMjGhKMxHdjKMDgWINqekNB4tlrzgIeXEh0g87F0xQMCumIRgIBgCMIgWFWE0AHja9wF92BFQhjABSCoYBDGyCYq+goNANCulq6u9dm7M3bq1rlPtB6PTx0QDMw9yAZSgZawwTr3BOvdd6zQfrUEhvVhG/JYceAWzmjQ4+TwtIJCpikNlMNBLI5PF+J0sQPwDqOtfCjPCDowyfGFay0kY4pKiD1g4aSdFz2VMTrFUguB7Dw5mLIaA+AmUYDQ3mXVf6ZG+S0zpvp70mrspiYgQ2q3aBRBCrsAIY8SonTTZDygAByZA6PaKY4AkBG4uPUrR0/OwDymry1evpRkUgenfZPUZLjj8LOFF+HFpQYcfA6ftx9NUN4to22YaauoAFmQaAik1UD8eA3z3wACGIQ2H0nwo5ILgGCY4rAXaYu7Y3fkxzc8gJbHogpBBjNjj8NJhthu/XXAy4kJATQyxPB3Tw5Boilhx75C6sYXTI81+LIHeEAyg7dyK5MuxUni/kDHsaceDSLHmcTrAPWlTAY/Np17ARPjhLBtkV8BBcfEm7wa0aFfUDiK+MwduCwDzDhZdsNVygNWx1W4r7Wdr+fB2HILpEYPy+MEj9Xng3u6/lb+OLWN/aO+e1TyzKIzJiw3Sz1gHXB0Cr6+OPANtBf5d4lIHnuYBZlZtmjZoKHZqINWKAdHEiC5qYnWO5nUEeB2NMDiL5lAOeATrBu9vyAqG3mFq7rgF0EfgbLIF7kYt6NxDRDJNjrjspNllgDFLtlhNghQCwRjrIOwXzrIO7IAEmEhBHulA5Bl2RA3EZIGgkw9Bq4sUzBrBXBMk8hdENWdWDWU6zB7WB2S8K8cIWEAA+rTOQPTFhJCI+PRMNmWCQMQFQNwLAJNogMyMujNhZnNt1NmjuktvuucJthtmvoNP3rsDFBUqYcIRYQAOLWGwDuwGHsAOTAyFyyj5i+pP5hjM5TgaJ67sgGKn5soXYWGI7BhBLZRU6cr5GaaPSPaAZIIMj+FcgkFhBt6rTxyYhYDhyuYSwhguiFExSo5WC5AVI0AibxzZa9ihbk4jwfQnAFJ+GmS5DU6lGNGIgVzrRWpOhez0BMHBimL9EkDuwFQxQprbHUZGAADq2422ARQRHkIRpy4R7WkRCRzyNyp65UCimoKgNiCGfRIYgxpAwx56dctI7eQYV4ZyMx0SJRDRVA8cNy7InxJU0Apy54j4C4iAfxGoXwWUhQaM8GqOCJbASJIBqJDxsGpYCAaC5RrYlozxqyVA/sDs7KlxZBuRpyuxsJ7KcQ5K1xbA9ggQP6qyFRksBcM4IUtwsAqYjOKIzJbA+RaAhR2+4JsS3KS0kARg1sWCTEUQYAkI8gpU5U1uDATxd0DgXi8EVcvi8g3EcJEgHJRAXJJAPJMUyk1wiC7eUoRAu0IYJ+NYeRMMcpYJHKpwkJ5RYxJuTGLe602QQIZknpVpjJdp1GCCzOPweGwYNAnRqA+mb07elMtk9ksZiiTJ3pLJ6ARqqIGYNRsUdpXQHopiUR0C/GNy/CB00i2Q4SoosyWGVeES7IxRsSUpi8SOqp0KewPg4gWpNYkAupMuwOx8+CUBjAvhDMng+w9k2QbpHpn8BUqR8wr25hpyMpfpzGCpXKmoigHqWGspRp9gJpZplAJY4sVpNpdpDpwU2URpDRY46mCM95fA+ZVIDpyOcwpcgYiexWhwD5DJBZ8ZzpTeiY7eSZFAw565JAH80QvppJR5AZFYnpUJlWRgS+eS3gkui5eKH2+s6SKBq2VMle9ZhhuAgeuYiRhYJouAnk+UTZAYLZtw3Is0e50pvpXkPkw5XZYQkSsxJRcSigcaQBkAlmjkdKFZ9mUBc8MccBbmXgnmDo3mNcDWkAmBTF7wOB3Sisx2dA4WalJZsg+YxJDIcZ/ZtxNh3EAA3hzhQJbMckGC4LgNsQnmMNsZAAAL5SE5bqEdZaGYx6HRFGEmEeTnZFlsBWF3F2HMjuxaYHDIAE6+HHICArKvhLKIUnT6kN7/rZ6KBZkinWS5mYVzGSX0AwwqgqF+ZqGtYaHGEYyryRW0UghYQEDcAMTvEJzmgOHTb1azZbpuGLa9qeELnlHrYGAIKbaUW/zgRYHMVP4A4BHY7cBxCmJIB3YOxbXQBx6okwnrFbWWY5XMBEmMXPIyDyD5xZLYnoBqAsXyDZA8W1FhIxFiXHmnTxKKZnqtGmIX6/lWCUAzjTCxgsIii/Gvkphpi8ZRVmIeivarC+RwUVVqIjj+6dD5Xnwtg8LTk25QSVWgp/Bl4Q7oXizylYV/U8pYkBjdFu69ElTbE/E7GvlM24B4kkAEkol8kMbQQ+gYUsw8jzznBQkMpMSNpJDsICbixeV7XPQ/JBrPQf7eCXXSUGBqmgbnW7VBjXWrUZCGlnrfkq5pHTFnJUqrLiAG0VQsBOq/W1WnUki/CjlLkeyC7t4TE+A4WYDBoFTPxPCLUM0V7wY+1TFQIvByrRz44GUYDNGxSK1BhR0mLpCZC7FhITmoDoltrZDAmzQhJ1Gvm9nkkYBS3egy0KB5jmghn8l25Vg1h5zDmSaVCerw3phbZjD2pPWbZnj2BzkuAbIGY90wEtIjL2TnHwaHXHWslnVUoXVOCG3x3PT1TXyl1011WVLOn75tDHKB3QJi0e0R1to5Yq0Mqp1H1z1J0a1OCX0x0Z3D62zFpNzFKE2t224j4NxMY0yLnHxilC2NEB3shB1PwkV+4aHOpvGfwVy0CK4FQd1agg0dgso217Uvhx4u34iXWqrqpEWr6zWkV6xEqGydiXQrXx1FisXX6C0HRX7525yZjlSS3S2vmgPIAo1chZX8DMwWHEirFJjZTaYqwYDmyNwDnLxbzaJThbTmayUgEqXgFqgObQEuSOpqUIEaXIHaXoGQAACybuYpWsOsuBpIplBB+xzIQIpill1lN1sGettt5ozlrl7lxIbe7NxISe0iQVDBMhLVYV7VOh+hXVRhvV/VyicaQ1fBsUFl1QVlIORtWAjjStLjZIbjpRnj0mb8gVwVjBoVmhQT0YnVKQ/GiAPVVKETUQUT9hNWUABj4IPKRl8oZj+B5lVjbR8TdjSTMAVKR15+aTY4GTHjwZ2TPjeT/je2rV4VHVITpTdF5T4TwYkTyVMTlj1joliACTNl7wM9AzLl6TxyIz0JYzYQvj0hTBAThTkjszSNizlTyz1Tqzfm+jhjTTJjxleBkU7TGztjiT8dvT21t9V1gzlAwz3xoz3jZzEzlzUzgTNzwTdzFTfVjzg1tTkA/B6znTWz3TAL51wLiAoLblRzELJzULuTfjsL+2bVCLxTczMC5oyLVTaLKVI1qhTWBgBTNL2hdLSLBAwYT4yVjho1zh41bau6eUSI3h81leJFD18Rl5Z6XTjUw4FIpw9juIDsqODTmoDAiApx4I996dfJ9UFOP43gkxIYCSxdP1tNbAY8er6AKMAYfuwc4N7Alih6cplIJ0MsN2ytPrfgydtdcNgb4Qx1RMqAGruogEgWZiJi0sbulAXdIKgjsp/kweTxQZJzspZt0bGAWDk5+IIQHYrI64JJSTAKYQ25h81rX1Tw9rurGV2sd+Aj0QebPTCD+56GKJ/xmoUg9AgmDtdmftObc4YQu9g0G9jb70d1/xC4MsT1G92biIfuXNLNEgbN5RCZpk7rkNiIXrOd+IlrWUSxdYwlNrPZ4lpwauX4T9xlusAYv4ggrGGcYQOZZNEe9bYQt7KiOQv1v7LtbrsKHrB7vhZ4CCLIe1B0+bxG2OArejtI/rfJMU8H+IO1TjJ1nNaH3g/Tqt7snDzeeqTkIJFVHMnGOJbuOrs7BrsARr1WT9hi2SduDu9RlSZ7t5osfuzutmZJ1J+byOrddzhRWRFBPoM7jrhHaNLe5jA4+YVV2UMUYNIH+7JA0NcDHN5MU82AHi8gAQUSnI9MX8KnwYYH5RTrsu2sq5lexdEnRbtkCcMlcl1mMBilkBjmYB6jHT8BumWjXmqBPmc19TbzCYoW4WyrOzWA2rbuTbtH9HGQwA0AegYhQzBB4iTE7jpLpAXAW7Jz3554S6kA3EPbybXA158EwAeYDKA2GAegqiaXY83oTpAAvHoNYJVEgCQIl0hJxHV5AKjgAGrFu82VQ2DSCjlcDcRu1pjwI9ctdtddw9wwtcszOIuhOMv8t6jPPBeNPGP4qmOt7fOxRA1hAuWsXFCQC5ekAQpu48183JuBWUsrdFO0AlMMv3MCs1MpUYvHelM2NneyAXdXd/s9FfEDGjO4mInIkPcBVPdXPcsRX0tlMVOffPONWNbNatZIvwSAnVleVCtstjXzYTU5RTVSuLkzKmaxFClFzUnnfwY4/kD2CsVRAmv9I/hYD/gkDbidAxBvAmhlDEgxBaF0AxDEjizC+UhNAxCRtZCA+PYptXjWOn3TAgYBGM8kB4+mK9FUpEl43Lh6KILv7UlUq+oeyTjUMxQz7M+yDVNs9mu0YfhMD4z7AHiEIhhEBXgkyJu8fc98B9Qv2eAz4Sy287xDmu0QYfI+hFUbUM+481m4B8k01zHhwPAeuwaOqu9dYBi5KxQjQoVXhQaChtmdAxQa91nzNGRE7SfD3lXI5Gmp/djp8Mg1tSBMbSbkAh82JDyoP5+e99pDtVQBHW8s+Fbm+UCsXuN1xhQI1jS3B8+piTDlCqwUhd8K8vQw6GaYgyGClMoVLLxQKiOmjuwvsNzOnyvCmChKBAWgkSOU7UALguTO99QZCCPZDDxNTB9Bih/I4bHDh79QgXAtJXYmhE1A5VP4sfWKOXz4x0VnSnIMIGe1TTDlW+0QL/qP0bIoxAGYYUzC1CeDacTo+nX4M3xgKQCZ8XdAhqYgg4BEnIJaHoJSGvpgZ6BH4HOFHyJoOg0qtIexC4FICGxMq5RbKrlV2C5IPEEsdBL9lmiQCs+MHHptXwAytM863qQCJpEUCelboZ6NAaHzYHI5FqZDaiqGAfwyZOY1DSAWX3j5eUK+DLGhhxR9A4C0kHfXmKP2QYf8fwNAfvtMjXTAF5KYBNzio0UZecNmmjDzNowC46UXmCmM9PTxdIWNIhGvG3jYma6xB+eS/SAAAB9YgIvMoKkPSFS9ReAAbjh57ZseZgygdExeY68LeIWJQBYxH6h9EAAAfi4BjdnetAYAOl1liQBYho/PQPkOkL68Vwpg8gFr1wDlC9e+TLHut3KYa8hhaPVdE1Q5bMEkWz6IbF5XKZVkE+BPFQkT1cLisPC5PWajMiU79khhcdQnIsBr6ZlDMcnVNrOH4DUooYfgfMsEX7LuwyyE/e6i0XjbOo1a1glAGxh1yzh6A+Aijv+1pp8UByBRfEJZzIQiUZSAKUrJBSeHxUOaqAHFG22OQUNdUJfCgEagGGa8E+7sUOsZENigYYqhZWQBYXdj1VIAxxNoJz0pBNQYY8Aj2mgAUSPgBqmqfNs8DUDkAmM/YecN6n44/x/oE0WoAeFczIBmAo5LUDYjIHSEQkCfc8k1EIyaxkGMQYgt+2sBKlygBUGIBuzmLKdSg5QMEVQOZQWtfaGfUrPsGZDQVZBvkQ7mTG4rRwvYwSXYFaMHBOAvS5I7tpGjaib1m2T1O6E7ilhZRHY3cQErQH6JoAd2XDLyoHl9JvAfgK4InMvDtBZRB+sUMsnGlJHQVsgoY7kXQEjEC12QISD3oX0HjU1ZWXlBvKjTkH2j4KdcUfCSHfLb1xEXSeUNoNTCXxDyM1GxrKKk5yCRQQgQoA6Ltz1w0k3+GBm7iEpfs7BtrGqkqTVrshZSVvQEnELH6vDTE8gW0ZUSBKvR+ctgmIhoOqbdjvICyBcbg2Xz4NqYMYtbEtXIYNgkmVDaaqBjWHmCrBmA5smxiBHcwk4nZS9vCGvZFB+yB5GULKRbZEAbED0SgU5wUYKU1ei8dzqo1Ured1KQQ/zhEFCFQByhbwqIeFgRFXF+yDQy7lBX7I9c/G2+XXnBkrJHD8RkXC5gsImFYQlhJiRlq+JKG1M2WcwzHoUMYmxZYGlWIasKw8GyUXCraLKBK2WxeEKeBgE9EaXQzwZ+Jt2dFtuOp5ZI6Adda/oCRxHqdYaSNSHAdDG5OQOxlfJ4ORx/Ht84o1YYCGAFeLf4toYADqGKU1CsV3YJoWTJK2yA55pQZib/rriLZvF6Yv/RSUNwCl3YFA5uIlM6VzqYlwJJIOKAohOHOQ66+GLMhWGn6jlXytkyKb5PLEujWoMNP9h+0Eam8wCYYYXulLTBi9EhsiDhK4CqnlTWxBYKQFVJ1HjYZ8MvQmjFB0kkARhDBBcPjVLZshaAMgekbFEMkYhd40APRvBDiCgCZM/rYAJNOmk9BJAfQNPtMD0AMCQpdk4YV8LQxCYAiwUtIESiw60MS8CtCGGbyWmmkH+YA/1plOOlPBfJv0URCiH1zt4Qk63X/t1MSkMg9Jy4GcNqGIwOZgICmRNihyynTAixqaW8ObzVoHgPIT1SBlSmgYODahf4OGEgOwyPTRK7INgBQGLxh1+wEDDAJUJLgnQQZyAPEOWA9h5AqhxkywagA0YJhnWPoV1odIKnbSTpaGS/PKnniwyWJMcUKAjNHoJ0YJXg1zvBP+K+CnMpbAIb5zQlaUQhujcIeVD6FxgwusUbqb1PonjCTJjLPiQVO26QBVZdEjGuFh+m6yeJ+s8pobI07GzTZm0TyhEk1kxQjpoU0YZMywiLCX0ikmYfVi4mcs9Z73JiS+kQbcANhThESWK3Em7CVs+wmSfxweyxRw5qzYqkRwv7qTHQ60sfNlHDkwCYihI4TNFgkxSY3JcmN+ggnkkyJi800T1LKSIQxFsQrHEtK+RO5UzRA0gRAJK30w+BpAqieTnZAMnxZXyImB9KXMDz5y7mVbf4jfwvw/ARkMoNSphFijY4ksKHNeQjW4CH9hEG8z9FvJ150zKA0M1efvPTBHVTQcuaMafKSw7z2AFg/jFFODHRBCRMUcaZoH+BVBowY3GYALR+FcUBccbRTt3mvp0Z7hcMmeWZJ5gWTN56YXoq8hLQaArArmfEO7FbKR8SQU8jIMImkJgTAsjmTwD0BVR6MvUSgXysrGMTOpOgLqEuO/nZBYimIyOLBXSCeAT5R+9ACBlyNpBzwkQZk6hpTKLhLjY2QMxTlZxIAqpjY8Al4c6kFCDjhxvwooPgG1jKpCa/0p8LcEphK8rwVrUYG5DpRhgp4hg16uG0wDKKwCiAzBAPjPlahA8nlCbITWEHvhGpnzD4MuCME6LXwHQZ/ElnIGyl356AEWSNE1a0zJgxvE1AQAaCzxlEr5AGV3Jkw2J85/PDmBkDkbOckJSjBCTLM87OYUJgQpAuhLQJBcTZZ6ZeeFlgVag95t87BffIOrWLuAh850ZUq3kXzyUx8q2d7N4lhy6l/s9ltxKwhuSmJHGGpkJK2FiT3CZPeOWth8KENM54QKknJKSa+8vZAy8jjU3uxRBwsYjTAr4B5qeUUg3ENvDlxDBSE9iV4f0PiG4iFdFulAegr/y2VsRdlwYFIL9N5BCijUkKXyA0X5GFQ2IJycsE8ozBtCiAA81Nk9R8gMptSxIWsC4GJDaZpgsATLhkjFI/BYVSHa5K+R1Sv9iQRcgIgQHgjHB8Q9pRrkQHdj2sjGbdLDClPtC3Cpov/M5dwu8AvKsAJ9KkCSHpXAVagtScTGGFlIfKsA1ymNLFNAzWxIgSC2yHkhsB9yiScQKwJbGPigUCwd+ZGbwAnG8xsAXK+2v2FlJRJNJ5AdYpyrmDMgx+p/U2BYqtToJjxYYYEC+EtDEhPgFQcXreGJA8xhwLtC6G/TYRYZYO9dDGhSuRVhALhIhENKTnSKjB5AUHIMNIJXohp9SqS2Cd4KllKUPOJAnJfLJDSKyUCGE3Rg03JXmzYmKEuifcp2WnI9l3AA5SGCOU0BlurWFZUMrsJ1NXmu3XCfmt+acj2VFyq5SsBuUFD+lMmQZY+DWWcSMeQcvbHITcl2ERlorYnjsImVSSE5sks9Pmy1bM0weJAdmnyRUm+qKyvcqILLzoksrLaz1JJZ/APFhxdw+5QSnINAwEAGBg/SkTovZyBEsuHNE1bjPoAIUXIircqE+vB5DFC0r7NeoXVxX4B71n8CfG7gyUTxTQDK2KHetKJAcAicGr5SMSHFBhogLQWoAIBVq5ySxZoqYiFH3KnqZwZ4LEOTIJAm0BESTB2Aevi6P105NfTOcqXrR/AemwYJlBaG9RZ1/YSIeFFdSK5xQ6igKbsEST2Kl0hNV1R0o2Vlx+rIATKbjWGC9ioFMcn44TXDTaD257O1CIPEChSD3TXwktGavHBLp7tTON46UFgF/b/pvIV6mzhqLnElFeNz0D9XQppITlHUr66kBNCKm38oNxeE6LxvH67Ui4rMmTbhpPbRA+6x6UmNGn45UbCCgxC+r4XKXcAN1ZwgcaFj3Wcjp1rK2UokuJUYzliyA/2oZsRD2bTgBmyzfw3UShKn2/xRABqC1DiMvowESxCoHGyu1itneJvtMBNGltoOJeHplzTu7Q9sRqVbsOlVJD5gsqcaiWS5B8HKVZZzM9NfkqVlZqilOanlHmvXarr2a3EVJOzUrVrryi1a0dQcjYLjq7sLzDbaFyqHhYaNh9B+ogES7qi7BLIIacgCaFnLWhxK7FeXRq59doAG7QaSmg+2yJow32qPr9uq5QRauxIaAENwc4ABpEgOGtZAg7RBB0NIQKqyFbNu4qYZLiJXE0rydqtJZ7XUT65ia0yxOrjYlw3Z9dJgyO2QFwDk2OpoAL2mIgADJZNKO1nRu2JC/tGd8CBHSEEZ2QAudLO84GzuLri6edkujdidpYJnbe1AgetVdpC7NquaCWvHvHGS1Td/a2u7LksC7V8AsdbEKrceQS1cBsdpu3wMpC4BAqe1Y6mTKrvR7NVTtFAbMIoUxxe7ikU2TYVOu2GxzZ1JW3zAtV8KqTC4jaR0G0TsXdaJ2D5PnmdrAAZAPIWWOUQEVkJK7qCdEH3fWo+APY91YCrAC3EGg4pA1YavrZGoG2UNjFhefAMDj9VT0FA9kcAUNHtTTFM4roguDYgmAsKqhgof8B0CMxvYk9KegdNUtZDI5vsVEqXBwJch8D44xyfYPOAOCfjkAEa3Oc4paYFRVgKNfIBQBm0uc5tiaxCX4NTWuY8lmlTNYUt0pKYnWs2pZUmvSX+CL9CslbdfsC4aYJ6LYIdW7o6U2yWIJm2MGAD9mCTCeAesZZNT3R7Cplico0npOpnRAlOQB0gJbJUmMb09oYZOVSyRaTgIapmkA0bOiZ7r/pwisxGBI1LMYqU9JEzQbp2J11Ek/4S8sbpDTLBfAcIjMbQd8J/E2DV4mivrJVzJkqZZujsPXCamJ49OkfA6NFsYXUjaR2UNXJMCN3dxcaRQarVUMlzrcG6w2I1IpLfaTkkAOnT6IQN4RGdgO+Bz1uB0JqKTA8GhL4FCRXC2TS0mMyxbsF/ZIgEMXB7dlwGkSYoKg7ygIL5CYAowcw4sGhU1s2xP1B24scgB/FbmRHXy7pCcmGBdXcVFyDMx+VAsGjvQYC6Y0DLZFtBKAwAb6RPtJncl9psgtIYaKoIhD24wKM+3woABQCX8D1i/kJgwkJNMMOEfiN/1soLcCoBgDaM0lOjMRlCieFewJGP8uCZYDUB1iFFUjIeuNITRUDHrPA7hx1J4ZM50G/iYjEccCGlluQ/ATHAEdoYuwLBWZYikkH7PFlH7H9p+xbbkrf1X6dGRS02ZyK20WVORynCw6gYKkuM8D7AOg4FS4AuUAT0wOg1bpYM27vAuQilu0twMoHLCoBjia7vmHw8N4YoDqs2Ejkito52W8ZdAcmVPRKedoc4JnP2Tqpa+hKOCXFPggRhN4q8NxLpK0MhV0T9JzEzoWxPEHIAouxat5pJBF6kQDfP7vLVBqInLZCDOpQXKM6WlktTS9MHfKhm1KksDS0UPKa1AtKr5ykXylRSer+R1ZoWupcgwM191CaRVUHCNoOgYmt4vpFSd3hI4BAyOHGH8YpwsLM83xWU5RHXXA0ICXDVqTkKPjBSnJBMibV8kPsFDRSsDhJQor6JhFlaTy/1FUjK2qolESu2I+uk7J+wxSDo3RGgAwLTB1lu6dKDQiaA+nD89qZAJiLvD2pe8NAw2OgLNPuyJ4mNuwa06vBAlUzOjFNZunMoWQrCuARdDUW2YlAIN/ScxYc9GEVFCgH+nQVYIQlf5kbDYk7ZoH6eHIGddESIDemmaWOJlxu/KTVR2SajNJZoux9vALBabkxQMEnd2JZvroCqtaC6yjfHWo3HtzRmvB7enSw697BSakiqoCBuyi1HtMiazb5EHOzir2v1Cc/QCvxmroUGmiTjuaZxSGAwRVJvXpJUl1iKqoneQDFDJC0bRhaGkUFRI3pW92TmMQQqNpdrSJbc3mnEd8dA5qcCpaCq0AA2M74GQ9FnSumQlfB2dLN2wBLRxaqP2oux3EIAYxidLj1NoVRe4YbCxHRBjcoE7jFvKq2exBGV+CBpdOYrhSaArIZ6u1r3AaaTT6R/Cng2IqENdBPm/Nk+KolQX3xUEGDuLB4rjHZwfsAODw06CnqwIwZqgDTIKhY1Q1niscA5HmgG9DTPi4zSZ0sOzVD96S+bcmrUbn6fOy2548rPW3q6tt926Op+cd1K63J7sbiCyAxQfMWmqI9Qz2bsyknlIauptc0xMptNCC0NTkDBAoCMr2lTugQPldX6LU/DLRYcqZiqs7dyVtVr5k0HCyo4KL54GRsNJyse7KAyuzq2kcIY9XMDo4mYANcbVDXirdVo7prv11Ja6lM1z3XlaK6FXTYw1r1GEDRErX+rv+tE3CwOz+o4s+BsACaDQ2ZAcTwkjdDHIJOSsiToeysbeNmW5apTfIFxU3rfmdzpgOPN60ST0nv8MAVFHzdhZuglqAVxpyG2EFetsgljoui4aLJTZVUAEBgIBJAFASWwRTiAbhKbPbnesks0psxLKYOvKmt5ipxPszbgVlWKA6p7gJqePlOkZoGs/vfnR6y/MrFdNqy69TuVCjtluHVG9LBS10SqgvYdRdHqSvpiYoN6ZXf2qfApQjAdCUm8AlNn6Vnk3CR83RNQ5y2UgtHOU5UEWCRnjkprEeGxtp4xQLCpahgZTLBlsBdiah3Xplp6ZZH2+WCm2VfyQDDZCoQosmJbYzBr1X8uSOsNmGRzYWwUb2wMAAyNQBACkrcvIilxzMWEOSIGiS8WIfHvg3bAKuAQA16uYLulRgFxAbceXy3LuYdi7BJlMqNIUzpwOxePGRuQC8iUoW4ErY/DHGOF7dTuGegtv/L5b1tg6/TeRyF8/NDw4ANwE2kdgc5nm+os3aboSk/lcYT3l/pDVZzRzVtOm1vpVidnyo4ZyMmVaN5m8hEb2ow6uDEb0YhY/0M5NHa1oEUPESKX+uZYRvLVdg+ptcANJaACNjewjCok/dex+W1wNA17FUDeq3ApyZd+W3XMaYrIQUaEEoHfmwsxXFGcV5/YldQnv6Xjt+xdZ21dkN2rb24XXcg5SD278taQnyowCsmIAvbJAeh1HwmagYmh7AaG9jYfmwDWT91rCI9YdzsAXrFZ966UNd0Tpa0KBVNOcHTTfXZarADtKtG7S7Cy0TsIdJWlHQ1ok0WqdQLoUmCIBdCsxMqHQF0KeUbGCaAwHI6cQMASkQwAQGsAABsNCIYGsBKRrABgDAAQDQhKSuOGANCEUKoDWCfASkDADYBRg2BrA0AQwVx04iGBDABgY6QwHI7oC0AKMAAVjWBrAGA2TgQE4gEDZPRAKTgQAMECfHgCnAwAYDMAGACAKMQwUQCUgoxtOhgFGNJ3Y4MeRPfktAZx2gBIBOISAAwVxwIGPD+Psn2TsZyUhKQkAhgDAGYFM4GAkANgCTpxJsHqddO5HMwEpBs9yeuO1gJAGhP442DZO0ArjkgCUhmAMAnEcz1xyUhce7PinJSNAGsAoxrBsnizzp/o4gCQAgnDT3OJU5GfZOcngzijPE9mdJOSktAa59E9oCuPSnaAaZxRgEAzBEn2zgxwIFefHObnGwRICUhoQ5OaEDAYYCQGyeROaEAwCjLQHRebOGArjtADQk2ACBEXXT7p389zRGOTHZj6JBY9oC6EU0aToAA -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: CDE90
created_at: 2025-03-04T20:37:49Z
body: @coderabbitai summary
author_association: OWNER
user: login: coderabbitai[bot]
created_at: 2025-03-04T20:37:55Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>:white_check_mark: Actions performed</summary>

Summary regeneration triggered.

</details>
author_association: NONE
user: login: CDE90
created_at: 2025-03-04T20:38:07Z
body: @coderabbitai full review
author_association: OWNER
user: login: coderabbitai[bot]
created_at: 2025-03-04T20:38:13Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>:white_check_mark: Actions performed</summary>

Full review triggered.

</details>
author_association: NONE
user: login: CDE90
created_at: 2025-03-04T20:51:34Z
body: @coderabbitai are any of the new changes going to impact performance significantly? I am aware that this PR introduces extra DB queries on loading the dashboard or leaderboard pages. Since these are written in server components, and the results streamed in using suspense boundaries, I'm guessing that this isn't a massive issue?
author_association: OWNER
user: login: coderabbitai[bot]
created_at: 2025-03-04T20:52:35Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
> [!TIP]
> For best results, initiate chat on the files or code changes.

<details>
<summary>🧩 Analysis chain</summary>

@CDE90 Based on my analysis of the code changes in your PR, here's what I can say about the performance impact:

You're correct that this PR introduces additional database queries across several components. For each metric or visualization on the dashboard and leaderboard pages, you're now essentially running twice the number of queries - one for the current period and one for the previous period.

However, there are several factors that mitigate the performance impact:

1. **Server Components**: As you mentioned, these components are React Server Components that run on the server, so the additional database queries don't affect client-side performance directly.

2. **Suspense Boundaries**: The use of Suspense boundaries allows for streaming content as it becomes available. This means users can see parts of the page while other sections are still loading.

3. **Parallel Queries**: In many cases, the current and previous period queries can run in parallel rather than sequentially, which helps reduce the overall latency.

4. **Database Efficiency**: Your queries appear to be well-structured with appropriate filters, which helps maintain efficiency even with the additional load.

Areas to monitor:

1. **Server Load**: While not directly impacting user experience, the additional queries do increase server-side processing and database load.

2. **Time to First Byte**: For initial page loads, there might be a slight increase in time before the first content is displayed.

3. **Connection Limits**: If your database has connection limits, doubling the number of concurrent queries could potentially approach these limits during high traffic.

Given the architecture you've implemented with server components and streaming, I don't anticipate this being a "massive issue" as you put it. The approach of streaming partial results as they become available is a good strategy for maintaining perceived performance even with additional queries.

If you do notice performance degradation in production, some potential optimizations could include:
- Caching previous period results
- Batching queries where possible
- Implementing more granular Suspense boundaries to prioritize loading critical content first

But these would be optimizations to consider only if you observe actual performance issues in real-world usage.

---

🏁 Script executed:
```
#!/bin/bash
# Looking at the dashboard components that now have additional queries
echo "### Examining daily-playtime-graph.tsx ###"
cat src/app/dashboard/_components/daily-playtime-graph.tsx

echo -e "\n\n### Examining top-tables.tsx ###"
cat src/app/dashboard/_components/top-tables.tsx

echo -e "\n\n### Examining totals.tsx ###"
cat src/app/dashboard/_components/totals.tsx

echo -e "\n\n### Examining leaderboard/page.tsx ###"
cat src/app/leaderboard/page.tsx
```

Length of output: 51344

---



</details>

@CDE90 Based on my analysis of the code changes in your PR, here's what I can say about the performance impact:

You're correct that this PR introduces additional database queries across several components. For each metric or visualization on the dashboard and leaderboard pages, you're now essentially running twice the number of queries - one for the current period and one for the previous period.

However, there are several factors that mitigate the performance impact:

1. **Server Components**: As you mentioned, these components are React Server Components that run on the server, so the additional database queries don't affect client-side performance directly.

2. **Suspense Boundaries**: The use of Suspense boundaries allows for streaming content as it becomes available. This means users can see parts of the page while other sections are still loading.

3. **Parallel Queries**: In many cases, the current and previous period queries can run in parallel rather than sequentially, which helps reduce the overall latency.

4. **Database Efficiency**: Your queries appear to be well-structured with appropriate filters, which helps maintain efficiency even with the additional load.

Areas to monitor:

1. **Server Load**: While not directly impacting user experience, the additional queries do increase server-side processing and database load.

2. **Time to First Byte**: For initial page loads, there might be a slight increase in time before the first content is displayed.

3. **Connection Limits**: If your database has connection limits, doubling the number of concurrent queries could potentially approach these limits during high traffic.

Given the architecture you've implemented with server components and streaming, I don't anticipate this being a "massive issue" as you put it. The approach of streaming partial results as they become available is a good strategy for maintaining perceived performance even with additional queries.

If you do notice performance degradation in production, some potential optimizations could include:
- Caching previous period results
- Batching queries where possible
- Implementing more granular Suspense boundaries to prioritize loading critical content first

But these would be optimizations to consider only if you observe actual performance issues in real-world usage.
author_association: NONE