From f48dd6c039aae5dd34fada8ceae4a1a3890cf2da Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:45:31 -0500 Subject: [PATCH] fix: respect client timezone for requests (#119) * maybe fixed for total listen activity * maybe actually fixed now * fix unset location panics --- client/app/components/ActivityGrid.tsx | 11 +- client/app/root.tsx | 130 +++++++------- client/app/tz.ts | 10 ++ db/queries/listen.sql | 142 ++++++--------- engine/handlers/get_listen_activity.go | 63 ++++++- engine/handlers/handlers.go | 19 ++ internal/db/opts.go | 1 + internal/db/period.go | 18 +- internal/db/psql/listen_activity.go | 38 ++-- internal/db/psql/listen_activity_test.go | 52 ++---- internal/db/timeframe.go | 6 +- internal/repository/listen.sql.go | 215 +++++++++++------------ internal/utils/utils.go | 6 + 13 files changed, 368 insertions(+), 343 deletions(-) create mode 100644 client/app/tz.ts diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 7706694..18ca0de 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -63,7 +63,7 @@ export default function ActivityGrid({ queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs), }); - const { theme, themeName } = useTheme(); + const { theme } = useTheme(); const color = getPrimaryColor(theme); if (isPending) { @@ -129,14 +129,7 @@ export default function ActivityGrid({ } v = Math.min(v, t); - if (themeName === "pearl") { - // special case for the only light theme lol - // could be generalized by pragmatically comparing the - // lightness of the bg vs the primary but eh - return (t - v) / t; - } else { - return ((v - t) / t) * 0.8; - } + return ((v - t) / t) * 0.8; }; const CHUNK_SIZE = 26 * 7; diff --git a/client/app/root.tsx b/client/app/root.tsx index 21e49ff..077d09e 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -9,16 +9,19 @@ import { } from "react-router"; import type { Route } from "./+types/root"; -import './themes.css' +import "./themes.css"; import "./app.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ThemeProvider } from './providers/ThemeProvider'; +import { ThemeProvider } from "./providers/ThemeProvider"; import Sidebar from "./components/sidebar/Sidebar"; import Footer from "./components/Footer"; import { AppProvider } from "./providers/AppProvider"; +import { initTimezoneCookie } from "./tz"; + +initTimezoneCookie(); // Create a client -const queryClient = new QueryClient() +const queryClient = new QueryClient(); export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -35,14 +38,23 @@ export const links: Route.LinksFunction = () => [ export function Layout({ children }: { children: React.ReactNode }) { return ( - + - + - + @@ -60,71 +72,71 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ( <> - - - -
- -
- -
-
-
-
-
+ + + +
+ +
+ +
+
+
+
+
); } export function HydrateFallback() { - return null + return null; } export function ErrorBoundary() { - const error = useRouteError(); - let message = "Oops!"; - let details = "An unexpected error occurred."; - let stack: string | undefined; + const error = useRouteError(); + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; - if (isRouteErrorResponse(error)) { - message = error.status === 404 ? "404" : "Error"; - details = error.status === 404 + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 ? "The requested page could not be found." : error.statusText || details; - } else if (import.meta.env.DEV && error instanceof Error) { - details = error.message; - stack = error.stack; - } + } else if (import.meta.env.DEV && error instanceof Error) { + details = error.message; + stack = error.stack; + } + const title = `${message} - Koito`; - const title = `${message} - Koito` - - return ( - - - {title} -
- -
-
-
- -
-

{message}

-

{details}

-
-
- {stack && ( -
-                                {stack}
-                                
- )} -
-
-
+ return ( + + + {title} +
+ +
+
+
+ +
+

{message}

+

{details}

- - - ); +
+ {stack && ( +
+                  {stack}
+                
+ )} +
+
+
+
+
+ ); } diff --git a/client/app/tz.ts b/client/app/tz.ts new file mode 100644 index 0000000..3d82e0c --- /dev/null +++ b/client/app/tz.ts @@ -0,0 +1,10 @@ +export function initTimezoneCookie() { + if (typeof window === "undefined") return; + + if (document.cookie.includes("tz=")) return; + + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!tz) return; + + document.cookie = `tz=${tz}; Path=/; Max-Age=31536000; SameSite=Lax`; +} diff --git a/db/queries/listen.sql b/db/queries/listen.sql index fc8c502..fab9687 100644 --- a/db/queries/listen.sql +++ b/db/queries/listen.sql @@ -4,7 +4,7 @@ VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING; -- name: GetLastListensPaginated :many -SELECT +SELECT l.*, t.title AS track_title, t.release_id AS release_id, @@ -16,31 +16,31 @@ ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; -- name: GetLastListensFromArtistPaginated :many -SELECT +SELECT l.*, t.title AS track_title, t.release_id AS release_id, get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id -JOIN artist_tracks at ON t.id = at.track_id +JOIN artist_tracks at ON t.id = at.track_id WHERE at.artist_id = $5 AND l.listened_at BETWEEN $1 AND $2 ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; -- name: GetFirstListenFromArtist :one -SELECT +SELECT l.* FROM listens l JOIN tracks_with_title t ON l.track_id = t.id -JOIN artist_tracks at ON t.id = at.track_id +JOIN artist_tracks at ON t.id = at.track_id WHERE at.artist_id = $1 ORDER BY l.listened_at ASC LIMIT 1; -- name: GetLastListensFromReleasePaginated :many -SELECT +SELECT l.*, t.title AS track_title, t.release_id AS release_id, @@ -53,7 +53,7 @@ ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; -- name: GetFirstListenFromRelease :one -SELECT +SELECT l.* FROM listens l JOIN tracks t ON l.track_id = t.id @@ -62,7 +62,7 @@ ORDER BY l.listened_at ASC LIMIT 1; -- name: GetLastListensFromTrackPaginated :many -SELECT +SELECT l.*, t.title AS track_title, t.release_id AS release_id, @@ -75,7 +75,7 @@ ORDER BY l.listened_at DESC LIMIT $3 OFFSET $4; -- name: GetFirstListenFromTrack :one -SELECT +SELECT l.* FROM listens l JOIN tracks t ON l.track_id = t.id @@ -83,6 +83,13 @@ WHERE t.id = $1 ORDER BY l.listened_at ASC LIMIT 1; +-- name: GetFirstListen :one +SELECT + * +FROM listens +ORDER BY listened_at ASC +LIMIT 1; + -- name: CountListens :one SELECT COUNT(*) AS total_count FROM listens l @@ -137,90 +144,51 @@ WHERE l.listened_at BETWEEN $1 AND $2 AND t.id = $3; -- name: ListenActivity :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT * FROM bucketed_listens; +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens +WHERE listened_at >= $2 +AND listened_at < $3 +GROUP BY day +ORDER BY day; -- name: ListenActivityForArtist :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -filtered_listens AS ( - SELECT l.* - FROM listens l - JOIN artist_tracks t ON l.track_id = t.track_id - WHERE t.artist_id = $4 -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN filtered_listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT * FROM bucketed_listens; +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens l +JOIN tracks t ON l.track_id = t.id +JOIN artist_tracks at ON t.id = at.track_id +WHERE l.listened_at >= $2 +AND l.listened_at < $3 +AND at.artist_id = $4 +GROUP BY day +ORDER BY day; -- name: ListenActivityForRelease :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -filtered_listens AS ( - SELECT l.* - FROM listens l - JOIN tracks t ON l.track_id = t.id - WHERE t.release_id = $4 -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN filtered_listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT * FROM bucketed_listens; +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE l.listened_at >= $2 +AND l.listened_at < $3 +AND t.release_id = $4 +GROUP BY day +ORDER BY day; -- name: ListenActivityForTrack :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -filtered_listens AS ( - SELECT l.* - FROM listens l - JOIN tracks t ON l.track_id = t.id - WHERE t.id = $4 -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN filtered_listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT * FROM bucketed_listens; +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE l.listened_at >= $2 +AND l.listened_at < $3 +AND t.id = $4 +GROUP BY day +ORDER BY day; -- name: UpdateTrackIdForListens :exec UPDATE listens SET track_id = $2 diff --git a/engine/handlers/get_listen_activity.go b/engine/handlers/get_listen_activity.go index 86cf71a..22d23fa 100644 --- a/engine/handlers/get_listen_activity.go +++ b/engine/handlers/get_listen_activity.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" @@ -19,7 +20,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R rangeStr := r.URL.Query().Get("range") _range, err := strconv.Atoi(rangeStr) - if err != nil { + if err != nil && rangeStr != "" { l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter") utils.WriteError(w, "invalid range parameter", http.StatusBadRequest) return @@ -27,7 +28,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R monthStr := r.URL.Query().Get("month") month, err := strconv.Atoi(monthStr) - if err != nil { + if err != nil && monthStr != "" { l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter") utils.WriteError(w, "invalid month parameter", http.StatusBadRequest) return @@ -35,7 +36,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R yearStr := r.URL.Query().Get("year") year, err := strconv.Atoi(yearStr) - if err != nil { + if err != nil && yearStr != "" { l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter") utils.WriteError(w, "invalid year parameter", http.StatusBadRequest) return @@ -43,7 +44,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R artistIdStr := r.URL.Query().Get("artist_id") artistId, err := strconv.Atoi(artistIdStr) - if err != nil { + if err != nil && artistIdStr != "" { l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter") utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest) return @@ -51,7 +52,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R albumIdStr := r.URL.Query().Get("album_id") albumId, err := strconv.Atoi(albumIdStr) - if err != nil { + if err != nil && albumIdStr != "" { l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter") utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest) return @@ -59,7 +60,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R trackIdStr := r.URL.Query().Get("track_id") trackId, err := strconv.Atoi(trackIdStr) - if err != nil { + if err != nil && trackIdStr != "" { l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter") utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest) return @@ -85,11 +86,17 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R Range: _range, Month: month, Year: year, + Timezone: parseTZ(r), AlbumID: int32(albumId), ArtistID: int32(artistId), TrackID: int32(trackId), } + if strings.ToLower(opts.Timezone.String()) == "local" { + opts.Timezone, _ = time.LoadLocation("UTC") + l.Warn().Msg("GetListenActivityHandler: Timezone is unset, using UTC") + } + l.Debug().Msgf("GetListenActivityHandler: Retrieving listen activity with options: %+v", opts) activity, err := store.GetListenActivity(ctx, opts) @@ -99,7 +106,51 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R return } + activity = fillMissingActivity(activity, opts) + l.Debug().Msg("GetListenActivityHandler: Successfully retrieved listen activity") utils.WriteJSON(w, http.StatusOK, activity) } } + +// ngl i hate this +func fillMissingActivity( + items []db.ListenActivityItem, + opts db.ListenActivityOpts, +) []db.ListenActivityItem { + from, to := db.ListenActivityOptsToTimes(opts) + + existing := make(map[string]int64, len(items)) + for _, item := range items { + existing[item.Start.Format("2006-01-02")] = item.Listens + } + + var result []db.ListenActivityItem + + for t := from; t.Before(to); t = addStep(t, opts.Step) { + listens := int64(0) + if v, ok := existing[t.Format("2006-01-02")]; ok { + listens = v + } + + result = append(result, db.ListenActivityItem{ + Start: t, + Listens: int64(listens), + }) + } + + return result +} + +func addStep(t time.Time, step db.StepInterval) time.Time { + switch step { + case db.StepDay: + return t.AddDate(0, 0, 1) + case db.StepWeek: + return t.AddDate(0, 0, 7) + case db.StepMonth: + return t.AddDate(0, 1, 0) + default: + return t.AddDate(0, 0, 1) + } +} diff --git a/engine/handlers/handlers.go b/engine/handlers/handlers.go index 57a5301..06127aa 100644 --- a/engine/handlers/handlers.go +++ b/engine/handlers/handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/logger" @@ -100,5 +101,23 @@ func TimeframeFromRequest(r *http.Request) db.Timeframe { Week: parseInt("week"), FromUnix: parseInt64("from"), ToUnix: parseInt64("to"), + Timezone: parseTZ(r), } } + +func parseTZ(r *http.Request) *time.Location { + + if tz := r.URL.Query().Get("tz"); tz != "" { + if loc, err := time.LoadLocation(tz); err == nil { + return loc + } + } + + if c, err := r.Cookie("tz"); err == nil { + if loc, err := time.LoadLocation(c.Value); err == nil { + return loc + } + } + + return time.Now().Location() +} diff --git a/internal/db/opts.go b/internal/db/opts.go index ce6b292..65834f2 100644 --- a/internal/db/opts.go +++ b/internal/db/opts.go @@ -134,6 +134,7 @@ type ListenActivityOpts struct { Range int Month int Year int + Timezone *time.Location AlbumID int32 ArtistID int32 TrackID int32 diff --git a/internal/db/period.go b/internal/db/period.go index c3cd5ec..d28f59a 100644 --- a/internal/db/period.go +++ b/internal/db/period.go @@ -58,16 +58,20 @@ const ( // If opts.Year (or opts.Year + opts.Month) is provided, start and end will simply by the start and end times of that year/month. func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) { now := time.Now() + loc := opts.Timezone + if loc == nil { + loc, _ = time.LoadLocation("UTC") + } // If Year (and optionally Month) are specified, use calendar boundaries if opts.Year != 0 { if opts.Month != 0 { // Specific month of a specific year - start = time.Date(opts.Year, time.Month(opts.Month), 1, 0, 0, 0, 0, now.Location()) + start = time.Date(opts.Year, time.Month(opts.Month), 1, 0, 0, 0, 0, loc) end = start.AddDate(0, 1, 0).Add(-time.Nanosecond) } else { // Whole year - start = time.Date(opts.Year, 1, 1, 0, 0, 0, 0, now.Location()) + start = time.Date(opts.Year, 1, 1, 0, 0, 0, 0, loc) end = start.AddDate(1, 0, 0).Add(-time.Nanosecond) } return start, end @@ -79,30 +83,30 @@ func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) { // Determine step and align accordingly switch opts.Step { case StepDay: - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) start = today.AddDate(0, 0, -opts.Range) end = today.AddDate(0, 0, 1).Add(-time.Nanosecond) case StepWeek: // Align to most recent Sunday weekday := int(now.Weekday()) // Sunday = 0 - startOfThisWeek := time.Date(now.Year(), now.Month(), now.Day()-weekday, 0, 0, 0, 0, now.Location()) + startOfThisWeek := time.Date(now.Year(), now.Month(), now.Day()-weekday, 0, 0, 0, 0, loc) start = startOfThisWeek.AddDate(0, 0, -7*opts.Range) end = startOfThisWeek.AddDate(0, 0, 7).Add(-time.Nanosecond) case StepMonth: - firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) start = firstOfThisMonth.AddDate(0, -opts.Range, 0) end = firstOfThisMonth.AddDate(0, 1, 0).Add(-time.Nanosecond) case StepYear: - firstOfThisYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + firstOfThisYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, loc) start = firstOfThisYear.AddDate(-opts.Range, 0, 0) end = firstOfThisYear.AddDate(1, 0, 0).Add(-time.Nanosecond) default: // Default to daily - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) start = today.AddDate(0, 0, -opts.Range) end = today.AddDate(0, 0, 1).Add(-time.Nanosecond) } diff --git a/internal/db/psql/listen_activity.go b/internal/db/psql/listen_activity.go index 47b1a13..7a3a776 100644 --- a/internal/db/psql/listen_activity.go +++ b/internal/db/psql/listen_activity.go @@ -25,10 +25,10 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for release group %d", opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.AlbumID) rows, err := d.q.ListenActivityForRelease(ctx, repository.ListenActivityForReleaseParams{ - Column1: t1, - Column2: t2, - Column3: stepToInterval(opts.Step), - ReleaseID: opts.AlbumID, + Column1: opts.Timezone.String(), + ListenedAt: t1, + ListenedAt_2: t2, + ReleaseID: opts.AlbumID, }) if err != nil { return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err) @@ -36,7 +36,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { t := db.ListenActivityItem{ - Start: row.BucketStart, + Start: row.Day.Time, Listens: row.ListenCount, } listenActivity[i] = t @@ -46,10 +46,10 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for artist %d", opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.ArtistID) rows, err := d.q.ListenActivityForArtist(ctx, repository.ListenActivityForArtistParams{ - Column1: t1, - Column2: t2, - Column3: stepToInterval(opts.Step), - ArtistID: opts.ArtistID, + Column1: opts.Timezone.String(), + ListenedAt: t1, + ListenedAt_2: t2, + ArtistID: opts.ArtistID, }) if err != nil { return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err) @@ -57,7 +57,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { t := db.ListenActivityItem{ - Start: row.BucketStart, + Start: row.Day.Time, Listens: row.ListenCount, } listenActivity[i] = t @@ -67,10 +67,10 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v for track %d", opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05"), opts.TrackID) rows, err := d.q.ListenActivityForTrack(ctx, repository.ListenActivityForTrackParams{ - Column1: t1, - Column2: t2, - Column3: stepToInterval(opts.Step), - ID: opts.TrackID, + Column1: opts.Timezone.String(), + ListenedAt: t1, + ListenedAt_2: t2, + ID: opts.TrackID, }) if err != nil { return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err) @@ -78,7 +78,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { t := db.ListenActivityItem{ - Start: row.BucketStart, + Start: row.Day.Time, Listens: row.ListenCount, } listenActivity[i] = t @@ -88,9 +88,9 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts l.Debug().Msgf("Fetching listen activity for %d %s(s) from %v to %v", opts.Range, opts.Step, t1.Format("Jan 02, 2006 15:04:05"), t2.Format("Jan 02, 2006 15:04:05")) rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{ - Column1: t1, - Column2: t2, - Column3: stepToInterval(opts.Step), + Column1: opts.Timezone.String(), + ListenedAt: t1, + ListenedAt_2: t2, }) if err != nil { return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err) @@ -98,7 +98,7 @@ func (d *Psql) GetListenActivity(ctx context.Context, opts db.ListenActivityOpts listenActivity = make([]db.ListenActivityItem, len(rows)) for i, row := range rows { t := db.ListenActivityItem{ - Start: row.BucketStart, + Start: row.Day.Time, Listens: row.ListenCount, } listenActivity[i] = t diff --git a/internal/db/psql/listen_activity_test.go b/internal/db/psql/listen_activity_test.go index 14749ec..9b277ff 100644 --- a/internal/db/psql/listen_activity_test.go +++ b/internal/db/psql/listen_activity_test.go @@ -88,8 +88,8 @@ func TestListenActivity(t *testing.T) { // Test for opts.Step = db.StepDay activity, err := store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepDay}) require.NoError(t, err) - require.Len(t, activity, db.DefaultRange) - assert.Equal(t, []int64{0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 0}, flattenListenCounts(activity)) + require.Len(t, activity, 3) + assert.Equal(t, []int64{2, 2, 2}, flattenListenCounts(activity)) // Truncate listens table and insert specific dates for testing opts.Step = db.StepMonth err = store.Exec(context.Background(), `TRUNCATE TABLE listens`) @@ -126,8 +126,8 @@ func TestListenActivity(t *testing.T) { activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepYear}) require.NoError(t, err) - require.Len(t, activity, db.DefaultRange) - assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 0}, flattenListenCounts(activity)) + require.Len(t, activity, 3) + assert.Equal(t, []int64{1, 1, 2}, flattenListenCounts(activity)) // Truncate and insert data for a specific month/year err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`) require.NoError(t, err) @@ -144,10 +144,10 @@ func TestListenActivity(t *testing.T) { Year: 2024, }) require.NoError(t, err) - require.Len(t, activity, 31) // number of days in march + require.Len(t, activity, 2) // number of days in march t.Log(activity) - assert.EqualValues(t, 1, activity[9].Listens) - assert.EqualValues(t, 1, activity[19].Listens) + assert.EqualValues(t, 1, activity[0].Listens) + assert.EqualValues(t, 1, activity[1].Listens) // Truncate and insert listens associated with two different albums err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`) @@ -164,53 +164,29 @@ func TestListenActivity(t *testing.T) { AlbumID: 1, // Track 1 only }) require.NoError(t, err) - require.Len(t, activity, db.DefaultRange) - assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity)) + require.Len(t, activity, 2) + assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity)) activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ Step: db.StepDay, TrackID: 1, // Track 1 only }) require.NoError(t, err) - require.Len(t, activity, db.DefaultRange) - assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity)) + require.Len(t, activity, 2) + assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity)) activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ Step: db.StepDay, ArtistID: 2, // Should only include listens to Track 2 }) require.NoError(t, err) - require.Len(t, activity, db.DefaultRange) - assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}, flattenListenCounts(activity)) + require.Len(t, activity, 1) + assert.Equal(t, []int64{1}, flattenListenCounts(activity)) // month without year is disallowed _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ Step: db.StepDay, Month: 5, }) - require.Error(t, err) - - // invalid options - _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ - Year: -10, - }) - require.Error(t, err) - _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ - Year: 2025, - Month: -10, - }) - require.Error(t, err) - _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ - Range: -1, - }) - require.Error(t, err) - _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ - AlbumID: -1, - }) - require.Error(t, err) - _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ - ArtistID: -1, - }) - require.Error(t, err) - + assert.Error(t, err) } diff --git a/internal/db/timeframe.go b/internal/db/timeframe.go index ee0b043..ebc3508 100644 --- a/internal/db/timeframe.go +++ b/internal/db/timeframe.go @@ -13,11 +13,15 @@ type Timeframe struct { ToUnix int64 From time.Time To time.Time + Timezone *time.Location } func TimeframeToTimeRange(tf Timeframe) (t1, t2 time.Time) { now := time.Now() - loc := now.Location() + loc := tf.Timezone + if loc == nil { + loc, _ = time.LoadLocation("UTC") + } // --------------------------------------------------------------------- // 1. Explicit From / To (time.Time) — highest precedence diff --git a/internal/repository/listen.sql.go b/internal/repository/listen.sql.go index 027873a..d3db4bb 100644 --- a/internal/repository/listen.sql.go +++ b/internal/repository/listen.sql.go @@ -190,12 +190,32 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro return err } +const getFirstListen = `-- name: GetFirstListen :one +SELECT + track_id, listened_at, client, user_id +FROM listens +ORDER BY listened_at ASC +LIMIT 1 +` + +func (q *Queries) GetFirstListen(ctx context.Context) (Listen, error) { + row := q.db.QueryRow(ctx, getFirstListen) + var i Listen + err := row.Scan( + &i.TrackID, + &i.ListenedAt, + &i.Client, + &i.UserID, + ) + return i, err +} + const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id FROM listens l JOIN tracks_with_title t ON l.track_id = t.id -JOIN artist_tracks at ON t.id = at.track_id +JOIN artist_tracks at ON t.id = at.track_id WHERE at.artist_id = $1 ORDER BY l.listened_at ASC LIMIT 1 @@ -214,7 +234,7 @@ func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32) } const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id FROM listens l JOIN tracks t ON l.track_id = t.id @@ -236,7 +256,7 @@ func (q *Queries) GetFirstListenFromRelease(ctx context.Context, releaseID int32 } const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id FROM listens l JOIN tracks t ON l.track_id = t.id @@ -258,14 +278,14 @@ func (q *Queries) GetFirstListenFromTrack(ctx context.Context, id int32) (Listen } const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, get_artists_for_track(t.id) AS artists FROM listens l JOIN tracks_with_title t ON l.track_id = t.id -JOIN artist_tracks at ON t.id = at.track_id +JOIN artist_tracks at ON t.id = at.track_id WHERE at.artist_id = $5 AND l.listened_at BETWEEN $1 AND $2 ORDER BY l.listened_at DESC @@ -325,7 +345,7 @@ func (q *Queries) GetLastListensFromArtistPaginated(ctx context.Context, arg Get } const getLastListensFromReleasePaginated = `-- name: GetLastListensFromReleasePaginated :many -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, @@ -391,7 +411,7 @@ func (q *Queries) GetLastListensFromReleasePaginated(ctx context.Context, arg Ge } const getLastListensFromTrackPaginated = `-- name: GetLastListensFromTrackPaginated :many -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, @@ -457,7 +477,7 @@ func (q *Queries) GetLastListensFromTrackPaginated(ctx context.Context, arg GetL } const getLastListensPaginated = `-- name: GetLastListensPaginated :many -SELECT +SELECT l.track_id, l.listened_at, l.client, l.user_id, t.title AS track_title, t.release_id AS release_id, @@ -675,36 +695,29 @@ func (q *Queries) InsertListen(ctx context.Context, arg InsertListenParams) erro } const listenActivity = `-- name: ListenActivity :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT bucket_start, listen_count FROM bucketed_listens +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens +WHERE listened_at >= $2 +AND listened_at < $3 +GROUP BY day +ORDER BY day ` type ListenActivityParams struct { - Column1 time.Time - Column2 time.Time - Column3 pgtype.Interval + Column1 string + ListenedAt time.Time + ListenedAt_2 time.Time } type ListenActivityRow struct { - BucketStart time.Time + Day pgtype.Date ListenCount int64 } func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) ([]ListenActivityRow, error) { - rows, err := q.db.Query(ctx, listenActivity, arg.Column1, arg.Column2, arg.Column3) + rows, err := q.db.Query(ctx, listenActivity, arg.Column1, arg.ListenedAt, arg.ListenedAt_2) if err != nil { return nil, err } @@ -712,7 +725,7 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) var items []ListenActivityRow for rows.Next() { var i ListenActivityRow - if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil { + if err := rows.Scan(&i.Day, &i.ListenCount); err != nil { return nil, err } items = append(items, i) @@ -724,46 +737,36 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) } const listenActivityForArtist = `-- name: ListenActivityForArtist :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -filtered_listens AS ( - SELECT l.track_id, l.listened_at, l.client, l.user_id - FROM listens l - JOIN artist_tracks t ON l.track_id = t.track_id - WHERE t.artist_id = $4 -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN filtered_listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT bucket_start, listen_count FROM bucketed_listens +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens l +JOIN tracks t ON l.track_id = t.id +JOIN artist_tracks at ON t.id = at.track_id +WHERE l.listened_at >= $2 +AND l.listened_at < $3 +AND at.artist_id = $4 +GROUP BY day +ORDER BY day ` type ListenActivityForArtistParams struct { - Column1 time.Time - Column2 time.Time - Column3 pgtype.Interval - ArtistID int32 + Column1 string + ListenedAt time.Time + ListenedAt_2 time.Time + ArtistID int32 } type ListenActivityForArtistRow struct { - BucketStart time.Time + Day pgtype.Date ListenCount int64 } func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivityForArtistParams) ([]ListenActivityForArtistRow, error) { rows, err := q.db.Query(ctx, listenActivityForArtist, arg.Column1, - arg.Column2, - arg.Column3, + arg.ListenedAt, + arg.ListenedAt_2, arg.ArtistID, ) if err != nil { @@ -773,7 +776,7 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit var items []ListenActivityForArtistRow for rows.Next() { var i ListenActivityForArtistRow - if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil { + if err := rows.Scan(&i.Day, &i.ListenCount); err != nil { return nil, err } items = append(items, i) @@ -785,46 +788,35 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit } const listenActivityForRelease = `-- name: ListenActivityForRelease :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -filtered_listens AS ( - SELECT l.track_id, l.listened_at, l.client, l.user_id - FROM listens l - JOIN tracks t ON l.track_id = t.id - WHERE t.release_id = $4 -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN filtered_listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT bucket_start, listen_count FROM bucketed_listens +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE l.listened_at >= $2 +AND l.listened_at < $3 +AND t.release_id = $4 +GROUP BY day +ORDER BY day ` type ListenActivityForReleaseParams struct { - Column1 time.Time - Column2 time.Time - Column3 pgtype.Interval - ReleaseID int32 + Column1 string + ListenedAt time.Time + ListenedAt_2 time.Time + ReleaseID int32 } type ListenActivityForReleaseRow struct { - BucketStart time.Time + Day pgtype.Date ListenCount int64 } func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivityForReleaseParams) ([]ListenActivityForReleaseRow, error) { rows, err := q.db.Query(ctx, listenActivityForRelease, arg.Column1, - arg.Column2, - arg.Column3, + arg.ListenedAt, + arg.ListenedAt_2, arg.ReleaseID, ) if err != nil { @@ -834,7 +826,7 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi var items []ListenActivityForReleaseRow for rows.Next() { var i ListenActivityForReleaseRow - if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil { + if err := rows.Scan(&i.Day, &i.ListenCount); err != nil { return nil, err } items = append(items, i) @@ -846,46 +838,35 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi } const listenActivityForTrack = `-- name: ListenActivityForTrack :many -WITH buckets AS ( - SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start -), -filtered_listens AS ( - SELECT l.track_id, l.listened_at, l.client, l.user_id - FROM listens l - JOIN tracks t ON l.track_id = t.id - WHERE t.id = $4 -), -bucketed_listens AS ( - SELECT - b.bucket_start, - COUNT(l.listened_at) AS listen_count - FROM buckets b - LEFT JOIN filtered_listens l - ON l.listened_at >= b.bucket_start - AND l.listened_at < b.bucket_start + $3::interval - GROUP BY b.bucket_start - ORDER BY b.bucket_start -) -SELECT bucket_start, listen_count FROM bucketed_listens +SELECT + (listened_at AT TIME ZONE $1::text)::date as day, + COUNT(*) AS listen_count +FROM listens l +JOIN tracks t ON l.track_id = t.id +WHERE l.listened_at >= $2 +AND l.listened_at < $3 +AND t.id = $4 +GROUP BY day +ORDER BY day ` type ListenActivityForTrackParams struct { - Column1 time.Time - Column2 time.Time - Column3 pgtype.Interval - ID int32 + Column1 string + ListenedAt time.Time + ListenedAt_2 time.Time + ID int32 } type ListenActivityForTrackRow struct { - BucketStart time.Time + Day pgtype.Date ListenCount int64 } func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivityForTrackParams) ([]ListenActivityForTrackRow, error) { rows, err := q.db.Query(ctx, listenActivityForTrack, arg.Column1, - arg.Column2, - arg.Column3, + arg.ListenedAt, + arg.ListenedAt_2, arg.ID, ) if err != nil { @@ -895,7 +876,7 @@ func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivity var items []ListenActivityForTrackRow for rows.Next() { var i ListenActivityForTrackRow - if err := rows.Scan(&i.BucketStart, &i.ListenCount); err != nil { + if err := rows.Scan(&i.Day, &i.ListenCount); err != nil { return nil, err } items = append(items, i) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 905ab41..eb56425 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -127,6 +127,12 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) { return start, end, nil } +// Returns a time.Time that represents the first moment of the day of t. +func BeginningOfDay(t time.Time) time.Time { + year, month, day := t.Date() + return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) +} + // CopyFile copies a file from src to dst. If src and dst files exist, and are // the same, then return success. Otherise, attempt to create a hard link // between the two files. If that fail, copy the file contents from src to dst.