fix: respect client timezone for requests (#119)

* maybe fixed for total listen activity

* maybe actually fixed now

* fix unset location panics
This commit is contained in:
Gabe Farrell 2026-01-10 01:45:31 -05:00 committed by GitHub
parent 2925425750
commit f48dd6c039
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 368 additions and 343 deletions

View file

@ -63,7 +63,7 @@ export default function ActivityGrid({
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs), queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
}); });
const { theme, themeName } = useTheme(); const { theme } = useTheme();
const color = getPrimaryColor(theme); const color = getPrimaryColor(theme);
if (isPending) { if (isPending) {
@ -129,14 +129,7 @@ export default function ActivityGrid({
} }
v = Math.min(v, t); v = Math.min(v, t);
if (themeName === "pearl") { return ((v - t) / t) * 0.8;
// 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;
}
}; };
const CHUNK_SIZE = 26 * 7; const CHUNK_SIZE = 26 * 7;

View file

@ -9,16 +9,19 @@ import {
} from "react-router"; } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import './themes.css' import "./themes.css";
import "./app.css"; import "./app.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from './providers/ThemeProvider'; import { ThemeProvider } from "./providers/ThemeProvider";
import Sidebar from "./components/sidebar/Sidebar"; import Sidebar from "./components/sidebar/Sidebar";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import { AppProvider } from "./providers/AppProvider"; import { AppProvider } from "./providers/AppProvider";
import { initTimezoneCookie } from "./tz";
initTimezoneCookie();
// Create a client // Create a client
const queryClient = new QueryClient() const queryClient = new QueryClient();
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
@ -35,14 +38,23 @@ export const links: Route.LinksFunction = () => [
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" style={{backgroundColor: 'black'}}> <html lang="en" style={{ backgroundColor: "black" }}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link
rel="icon"
type="image/png"
href="/favicon-96x96.png"
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="Koito" /> <meta name="apple-mobile-web-app-title" content="Koito" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<Meta /> <Meta />
@ -60,71 +72,71 @@ export function Layout({ children }: { children: React.ReactNode }) {
export default function App() { export default function App() {
return ( return (
<> <>
<AppProvider> <AppProvider>
<ThemeProvider> <ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<div className="flex-col flex sm:flex-row"> <div className="flex-col flex sm:flex-row">
<Sidebar /> <Sidebar />
<div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]"> <div className="flex flex-col items-center mx-auto w-full ml-0 sm:ml-[40px]">
<Outlet /> <Outlet />
<Footer /> <Footer />
</div> </div>
</div> </div>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
</AppProvider> </AppProvider>
</> </>
); );
} }
export function HydrateFallback() { export function HydrateFallback() {
return null return null;
} }
export function ErrorBoundary() { export function ErrorBoundary() {
const error = useRouteError(); const error = useRouteError();
let message = "Oops!"; let message = "Oops!";
let details = "An unexpected error occurred."; let details = "An unexpected error occurred.";
let stack: string | undefined; let stack: string | undefined;
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"; message = error.status === 404 ? "404" : "Error";
details = error.status === 404 details =
error.status === 404
? "The requested page could not be found." ? "The requested page could not be found."
: error.statusText || details; : error.statusText || details;
} else if (import.meta.env.DEV && error instanceof Error) { } else if (import.meta.env.DEV && error instanceof Error) {
details = error.message; details = error.message;
stack = error.stack; stack = error.stack;
} }
const title = `${message} - Koito`;
const title = `${message} - Koito` return (
<AppProvider>
return ( <ThemeProvider>
<AppProvider> <title>{title}</title>
<ThemeProvider> <div className="flex">
<title>{title}</title> <Sidebar />
<div className="flex"> <div className="w-full flex flex-col">
<Sidebar /> <main className="pt-16 p-4 container mx-auto flex-grow">
<div className="w-full flex flex-col"> <div className="flex gap-4 items-end">
<main className="pt-16 p-4 container mx-auto flex-grow"> <img className="w-[200px] rounded" src="../yuu.jpg" />
<div className="flex gap-4 items-end"> <div>
<img className="w-[200px] rounded" src="../yuu.jpg" /> <h1>{message}</h1>
<div> <p>{details}</p>
<h1>{message}</h1>
<p>{details}</p>
</div>
</div>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
<Footer />
</div>
</div> </div>
</ThemeProvider> </div>
</AppProvider> {stack && (
); <pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
<Footer />
</div>
</div>
</ThemeProvider>
</AppProvider>
);
} }

10
client/app/tz.ts Normal file
View file

@ -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`;
}

View file

@ -4,7 +4,7 @@ VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- name: GetLastListensPaginated :many -- name: GetLastListensPaginated :many
SELECT SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
@ -16,31 +16,31 @@ ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetLastListensFromArtistPaginated :many -- name: GetLastListensFromArtistPaginated :many
SELECT SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
get_artists_for_track(t.id) AS artists get_artists_for_track(t.id) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id 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 WHERE at.artist_id = $5
AND l.listened_at BETWEEN $1 AND $2 AND l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromArtist :one -- name: GetFirstListenFromArtist :one
SELECT SELECT
l.* l.*
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id 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 WHERE at.artist_id = $1
ORDER BY l.listened_at ASC ORDER BY l.listened_at ASC
LIMIT 1; LIMIT 1;
-- name: GetLastListensFromReleasePaginated :many -- name: GetLastListensFromReleasePaginated :many
SELECT SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
@ -53,7 +53,7 @@ ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromRelease :one -- name: GetFirstListenFromRelease :one
SELECT SELECT
l.* l.*
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
@ -62,7 +62,7 @@ ORDER BY l.listened_at ASC
LIMIT 1; LIMIT 1;
-- name: GetLastListensFromTrackPaginated :many -- name: GetLastListensFromTrackPaginated :many
SELECT SELECT
l.*, l.*,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
@ -75,7 +75,7 @@ ORDER BY l.listened_at DESC
LIMIT $3 OFFSET $4; LIMIT $3 OFFSET $4;
-- name: GetFirstListenFromTrack :one -- name: GetFirstListenFromTrack :one
SELECT SELECT
l.* l.*
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id JOIN tracks t ON l.track_id = t.id
@ -83,6 +83,13 @@ WHERE t.id = $1
ORDER BY l.listened_at ASC ORDER BY l.listened_at ASC
LIMIT 1; LIMIT 1;
-- name: GetFirstListen :one
SELECT
*
FROM listens
ORDER BY listened_at ASC
LIMIT 1;
-- name: CountListens :one -- name: CountListens :one
SELECT COUNT(*) AS total_count SELECT COUNT(*) AS total_count
FROM listens l FROM listens l
@ -137,90 +144,51 @@ WHERE l.listened_at BETWEEN $1 AND $2
AND t.id = $3; AND t.id = $3;
-- name: ListenActivity :many -- name: ListenActivity :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
bucketed_listens AS ( FROM listens
SELECT WHERE listened_at >= $2
b.bucket_start, AND listened_at < $3
COUNT(l.listened_at) AS listen_count GROUP BY day
FROM buckets b ORDER BY day;
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;
-- name: ListenActivityForArtist :many -- name: ListenActivityForArtist :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.* JOIN tracks t ON l.track_id = t.id
FROM listens l JOIN artist_tracks at ON t.id = at.track_id
JOIN artist_tracks t ON l.track_id = t.track_id WHERE l.listened_at >= $2
WHERE t.artist_id = $4 AND l.listened_at < $3
), AND at.artist_id = $4
bucketed_listens AS ( GROUP BY day
SELECT ORDER BY day;
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;
-- name: ListenActivityForRelease :many -- name: ListenActivityForRelease :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.* JOIN tracks t ON l.track_id = t.id
FROM listens l WHERE l.listened_at >= $2
JOIN tracks t ON l.track_id = t.id AND l.listened_at < $3
WHERE t.release_id = $4 AND t.release_id = $4
), GROUP BY day
bucketed_listens AS ( ORDER BY day;
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;
-- name: ListenActivityForTrack :many -- name: ListenActivityForTrack :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.* JOIN tracks t ON l.track_id = t.id
FROM listens l WHERE l.listened_at >= $2
JOIN tracks t ON l.track_id = t.id AND l.listened_at < $3
WHERE t.id = $4 AND t.id = $4
), GROUP BY day
bucketed_listens AS ( ORDER BY day;
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;
-- name: UpdateTrackIdForListens :exec -- name: UpdateTrackIdForListens :exec
UPDATE listens SET track_id = $2 UPDATE listens SET track_id = $2

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "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") rangeStr := r.URL.Query().Get("range")
_range, err := strconv.Atoi(rangeStr) _range, err := strconv.Atoi(rangeStr)
if err != nil { if err != nil && rangeStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid range parameter")
utils.WriteError(w, "invalid range parameter", http.StatusBadRequest) utils.WriteError(w, "invalid range parameter", http.StatusBadRequest)
return return
@ -27,7 +28,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
monthStr := r.URL.Query().Get("month") monthStr := r.URL.Query().Get("month")
month, err := strconv.Atoi(monthStr) month, err := strconv.Atoi(monthStr)
if err != nil { if err != nil && monthStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid month parameter")
utils.WriteError(w, "invalid month parameter", http.StatusBadRequest) utils.WriteError(w, "invalid month parameter", http.StatusBadRequest)
return return
@ -35,7 +36,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
yearStr := r.URL.Query().Get("year") yearStr := r.URL.Query().Get("year")
year, err := strconv.Atoi(yearStr) year, err := strconv.Atoi(yearStr)
if err != nil { if err != nil && yearStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid year parameter")
utils.WriteError(w, "invalid year parameter", http.StatusBadRequest) utils.WriteError(w, "invalid year parameter", http.StatusBadRequest)
return return
@ -43,7 +44,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
artistIdStr := r.URL.Query().Get("artist_id") artistIdStr := r.URL.Query().Get("artist_id")
artistId, err := strconv.Atoi(artistIdStr) artistId, err := strconv.Atoi(artistIdStr)
if err != nil { if err != nil && artistIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid artist ID parameter")
utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest) utils.WriteError(w, "invalid artist ID parameter", http.StatusBadRequest)
return return
@ -51,7 +52,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
albumIdStr := r.URL.Query().Get("album_id") albumIdStr := r.URL.Query().Get("album_id")
albumId, err := strconv.Atoi(albumIdStr) albumId, err := strconv.Atoi(albumIdStr)
if err != nil { if err != nil && albumIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid album ID parameter")
utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest) utils.WriteError(w, "invalid album ID parameter", http.StatusBadRequest)
return return
@ -59,7 +60,7 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
trackIdStr := r.URL.Query().Get("track_id") trackIdStr := r.URL.Query().Get("track_id")
trackId, err := strconv.Atoi(trackIdStr) trackId, err := strconv.Atoi(trackIdStr)
if err != nil { if err != nil && trackIdStr != "" {
l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter") l.Debug().AnErr("error", err).Msg("GetListenActivityHandler: Invalid track ID parameter")
utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest) utils.WriteError(w, "invalid track ID parameter", http.StatusBadRequest)
return return
@ -85,11 +86,17 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
Range: _range, Range: _range,
Month: month, Month: month,
Year: year, Year: year,
Timezone: parseTZ(r),
AlbumID: int32(albumId), AlbumID: int32(albumId),
ArtistID: int32(artistId), ArtistID: int32(artistId),
TrackID: int32(trackId), 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) l.Debug().Msgf("GetListenActivityHandler: Retrieving listen activity with options: %+v", opts)
activity, err := store.GetListenActivity(ctx, opts) activity, err := store.GetListenActivity(ctx, opts)
@ -99,7 +106,51 @@ func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.R
return return
} }
activity = fillMissingActivity(activity, opts)
l.Debug().Msg("GetListenActivityHandler: Successfully retrieved listen activity") l.Debug().Msg("GetListenActivityHandler: Successfully retrieved listen activity")
utils.WriteJSON(w, http.StatusOK, 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)
}
}

View file

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gabehf/koito/internal/db" "github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger" "github.com/gabehf/koito/internal/logger"
@ -100,5 +101,23 @@ func TimeframeFromRequest(r *http.Request) db.Timeframe {
Week: parseInt("week"), Week: parseInt("week"),
FromUnix: parseInt64("from"), FromUnix: parseInt64("from"),
ToUnix: parseInt64("to"), 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()
}

View file

@ -134,6 +134,7 @@ type ListenActivityOpts struct {
Range int Range int
Month int Month int
Year int Year int
Timezone *time.Location
AlbumID int32 AlbumID int32
ArtistID int32 ArtistID int32
TrackID int32 TrackID int32

View file

@ -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. // 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) { func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
now := time.Now() now := time.Now()
loc := opts.Timezone
if loc == nil {
loc, _ = time.LoadLocation("UTC")
}
// If Year (and optionally Month) are specified, use calendar boundaries // If Year (and optionally Month) are specified, use calendar boundaries
if opts.Year != 0 { if opts.Year != 0 {
if opts.Month != 0 { if opts.Month != 0 {
// Specific month of a specific year // 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) end = start.AddDate(0, 1, 0).Add(-time.Nanosecond)
} else { } else {
// Whole year // 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) end = start.AddDate(1, 0, 0).Add(-time.Nanosecond)
} }
return start, end return start, end
@ -79,30 +83,30 @@ func ListenActivityOptsToTimes(opts ListenActivityOpts) (start, end time.Time) {
// Determine step and align accordingly // Determine step and align accordingly
switch opts.Step { switch opts.Step {
case StepDay: 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) start = today.AddDate(0, 0, -opts.Range)
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond) end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
case StepWeek: case StepWeek:
// Align to most recent Sunday // Align to most recent Sunday
weekday := int(now.Weekday()) // Sunday = 0 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) start = startOfThisWeek.AddDate(0, 0, -7*opts.Range)
end = startOfThisWeek.AddDate(0, 0, 7).Add(-time.Nanosecond) end = startOfThisWeek.AddDate(0, 0, 7).Add(-time.Nanosecond)
case StepMonth: 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) start = firstOfThisMonth.AddDate(0, -opts.Range, 0)
end = firstOfThisMonth.AddDate(0, 1, 0).Add(-time.Nanosecond) end = firstOfThisMonth.AddDate(0, 1, 0).Add(-time.Nanosecond)
case StepYear: 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) start = firstOfThisYear.AddDate(-opts.Range, 0, 0)
end = firstOfThisYear.AddDate(1, 0, 0).Add(-time.Nanosecond) end = firstOfThisYear.AddDate(1, 0, 0).Add(-time.Nanosecond)
default: default:
// Default to daily // 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) start = today.AddDate(0, 0, -opts.Range)
end = today.AddDate(0, 0, 1).Add(-time.Nanosecond) end = today.AddDate(0, 0, 1).Add(-time.Nanosecond)
} }

View file

@ -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", 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) 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{ rows, err := d.q.ListenActivityForRelease(ctx, repository.ListenActivityForReleaseParams{
Column1: t1, Column1: opts.Timezone.String(),
Column2: t2, ListenedAt: t1,
Column3: stepToInterval(opts.Step), ListenedAt_2: t2,
ReleaseID: opts.AlbumID, ReleaseID: opts.AlbumID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetListenActivity: ListenActivityForRelease: %w", err) 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)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
t := db.ListenActivityItem{ t := db.ListenActivityItem{
Start: row.BucketStart, Start: row.Day.Time,
Listens: row.ListenCount, Listens: row.ListenCount,
} }
listenActivity[i] = t 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", 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) 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{ rows, err := d.q.ListenActivityForArtist(ctx, repository.ListenActivityForArtistParams{
Column1: t1, Column1: opts.Timezone.String(),
Column2: t2, ListenedAt: t1,
Column3: stepToInterval(opts.Step), ListenedAt_2: t2,
ArtistID: opts.ArtistID, ArtistID: opts.ArtistID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetListenActivity: ListenActivityForArtist: %w", err) 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)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
t := db.ListenActivityItem{ t := db.ListenActivityItem{
Start: row.BucketStart, Start: row.Day.Time,
Listens: row.ListenCount, Listens: row.ListenCount,
} }
listenActivity[i] = t 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", 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) 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{ rows, err := d.q.ListenActivityForTrack(ctx, repository.ListenActivityForTrackParams{
Column1: t1, Column1: opts.Timezone.String(),
Column2: t2, ListenedAt: t1,
Column3: stepToInterval(opts.Step), ListenedAt_2: t2,
ID: opts.TrackID, ID: opts.TrackID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetListenActivity: ListenActivityForTrack: %w", err) 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)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
t := db.ListenActivityItem{ t := db.ListenActivityItem{
Start: row.BucketStart, Start: row.Day.Time,
Listens: row.ListenCount, Listens: row.ListenCount,
} }
listenActivity[i] = t 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", 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")) 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{ rows, err := d.q.ListenActivity(ctx, repository.ListenActivityParams{
Column1: t1, Column1: opts.Timezone.String(),
Column2: t2, ListenedAt: t1,
Column3: stepToInterval(opts.Step), ListenedAt_2: t2,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("GetListenActivity: ListenActivity: %w", err) 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)) listenActivity = make([]db.ListenActivityItem, len(rows))
for i, row := range rows { for i, row := range rows {
t := db.ListenActivityItem{ t := db.ListenActivityItem{
Start: row.BucketStart, Start: row.Day.Time,
Listens: row.ListenCount, Listens: row.ListenCount,
} }
listenActivity[i] = t listenActivity[i] = t

View file

@ -88,8 +88,8 @@ func TestListenActivity(t *testing.T) {
// Test for opts.Step = db.StepDay // Test for opts.Step = db.StepDay
activity, err := store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepDay}) activity, err := store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepDay})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, activity, db.DefaultRange) require.Len(t, activity, 3)
assert.Equal(t, []int64{0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 0}, flattenListenCounts(activity)) assert.Equal(t, []int64{2, 2, 2}, flattenListenCounts(activity))
// Truncate listens table and insert specific dates for testing opts.Step = db.StepMonth // Truncate listens table and insert specific dates for testing opts.Step = db.StepMonth
err = store.Exec(context.Background(), `TRUNCATE TABLE listens`) 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}) activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{Step: db.StepYear})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, activity, db.DefaultRange) require.Len(t, activity, 3)
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 0}, flattenListenCounts(activity)) assert.Equal(t, []int64{1, 1, 2}, flattenListenCounts(activity))
// Truncate and insert data for a specific month/year // Truncate and insert data for a specific month/year
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`) err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
require.NoError(t, err) require.NoError(t, err)
@ -144,10 +144,10 @@ func TestListenActivity(t *testing.T) {
Year: 2024, Year: 2024,
}) })
require.NoError(t, err) 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) t.Log(activity)
assert.EqualValues(t, 1, activity[9].Listens) assert.EqualValues(t, 1, activity[0].Listens)
assert.EqualValues(t, 1, activity[19].Listens) assert.EqualValues(t, 1, activity[1].Listens)
// Truncate and insert listens associated with two different albums // Truncate and insert listens associated with two different albums
err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`) err = store.Exec(context.Background(), `TRUNCATE TABLE listens RESTART IDENTITY`)
@ -164,53 +164,29 @@ func TestListenActivity(t *testing.T) {
AlbumID: 1, // Track 1 only AlbumID: 1, // Track 1 only
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, activity, db.DefaultRange) require.Len(t, activity, 2)
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity)) assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity))
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
Step: db.StepDay, Step: db.StepDay,
TrackID: 1, // Track 1 only TrackID: 1, // Track 1 only
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, activity, db.DefaultRange) require.Len(t, activity, 2)
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0}, flattenListenCounts(activity)) assert.Equal(t, []int64{1, 1}, flattenListenCounts(activity))
activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ activity, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
Step: db.StepDay, Step: db.StepDay,
ArtistID: 2, // Should only include listens to Track 2 ArtistID: 2, // Should only include listens to Track 2
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, activity, db.DefaultRange) require.Len(t, activity, 1)
assert.Equal(t, []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}, flattenListenCounts(activity)) assert.Equal(t, []int64{1}, flattenListenCounts(activity))
// month without year is disallowed // month without year is disallowed
_, err = store.GetListenActivity(ctx, db.ListenActivityOpts{ _, err = store.GetListenActivity(ctx, db.ListenActivityOpts{
Step: db.StepDay, Step: db.StepDay,
Month: 5, Month: 5,
}) })
require.Error(t, err) assert.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)
} }

View file

@ -13,11 +13,15 @@ type Timeframe struct {
ToUnix int64 ToUnix int64
From time.Time From time.Time
To time.Time To time.Time
Timezone *time.Location
} }
func TimeframeToTimeRange(tf Timeframe) (t1, t2 time.Time) { func TimeframeToTimeRange(tf Timeframe) (t1, t2 time.Time) {
now := time.Now() now := time.Now()
loc := now.Location() loc := tf.Timezone
if loc == nil {
loc, _ = time.LoadLocation("UTC")
}
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 1. Explicit From / To (time.Time) — highest precedence // 1. Explicit From / To (time.Time) — highest precedence

View file

@ -190,12 +190,32 @@ func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) erro
return err 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 const getFirstListenFromArtist = `-- name: GetFirstListenFromArtist :one
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id l.track_id, l.listened_at, l.client, l.user_id
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id 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 WHERE at.artist_id = $1
ORDER BY l.listened_at ASC ORDER BY l.listened_at ASC
LIMIT 1 LIMIT 1
@ -214,7 +234,7 @@ func (q *Queries) GetFirstListenFromArtist(ctx context.Context, artistID int32)
} }
const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one const getFirstListenFromRelease = `-- name: GetFirstListenFromRelease :one
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id l.track_id, l.listened_at, l.client, l.user_id
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id 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 const getFirstListenFromTrack = `-- name: GetFirstListenFromTrack :one
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id l.track_id, l.listened_at, l.client, l.user_id
FROM listens l FROM listens l
JOIN tracks t ON l.track_id = t.id 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 const getLastListensFromArtistPaginated = `-- name: GetLastListensFromArtistPaginated :many
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
get_artists_for_track(t.id) AS artists get_artists_for_track(t.id) AS artists
FROM listens l FROM listens l
JOIN tracks_with_title t ON l.track_id = t.id 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 WHERE at.artist_id = $5
AND l.listened_at BETWEEN $1 AND $2 AND l.listened_at BETWEEN $1 AND $2
ORDER BY l.listened_at DESC ORDER BY l.listened_at DESC
@ -325,7 +345,7 @@ func (q *Queries) GetLastListensFromArtistPaginated(ctx context.Context, arg Get
} }
const getLastListensFromReleasePaginated = `-- name: GetLastListensFromReleasePaginated :many const getLastListensFromReleasePaginated = `-- name: GetLastListensFromReleasePaginated :many
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
@ -391,7 +411,7 @@ func (q *Queries) GetLastListensFromReleasePaginated(ctx context.Context, arg Ge
} }
const getLastListensFromTrackPaginated = `-- name: GetLastListensFromTrackPaginated :many const getLastListensFromTrackPaginated = `-- name: GetLastListensFromTrackPaginated :many
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, t.release_id AS release_id,
@ -457,7 +477,7 @@ func (q *Queries) GetLastListensFromTrackPaginated(ctx context.Context, arg GetL
} }
const getLastListensPaginated = `-- name: GetLastListensPaginated :many const getLastListensPaginated = `-- name: GetLastListensPaginated :many
SELECT SELECT
l.track_id, l.listened_at, l.client, l.user_id, l.track_id, l.listened_at, l.client, l.user_id,
t.title AS track_title, t.title AS track_title,
t.release_id AS release_id, 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 const listenActivity = `-- name: ListenActivity :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
bucketed_listens AS ( FROM listens
SELECT WHERE listened_at >= $2
b.bucket_start, AND listened_at < $3
COUNT(l.listened_at) AS listen_count GROUP BY day
FROM buckets b ORDER BY day
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
` `
type ListenActivityParams struct { type ListenActivityParams struct {
Column1 time.Time Column1 string
Column2 time.Time ListenedAt time.Time
Column3 pgtype.Interval ListenedAt_2 time.Time
} }
type ListenActivityRow struct { type ListenActivityRow struct {
BucketStart time.Time Day pgtype.Date
ListenCount int64 ListenCount int64
} }
func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams) ([]ListenActivityRow, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -712,7 +725,7 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
var items []ListenActivityRow var items []ListenActivityRow
for rows.Next() { for rows.Next() {
var i ListenActivityRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@ -724,46 +737,36 @@ func (q *Queries) ListenActivity(ctx context.Context, arg ListenActivityParams)
} }
const listenActivityForArtist = `-- name: ListenActivityForArtist :many const listenActivityForArtist = `-- name: ListenActivityForArtist :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.track_id, l.listened_at, l.client, l.user_id JOIN tracks t ON l.track_id = t.id
FROM listens l JOIN artist_tracks at ON t.id = at.track_id
JOIN artist_tracks t ON l.track_id = t.track_id WHERE l.listened_at >= $2
WHERE t.artist_id = $4 AND l.listened_at < $3
), AND at.artist_id = $4
bucketed_listens AS ( GROUP BY day
SELECT ORDER BY day
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
` `
type ListenActivityForArtistParams struct { type ListenActivityForArtistParams struct {
Column1 time.Time Column1 string
Column2 time.Time ListenedAt time.Time
Column3 pgtype.Interval ListenedAt_2 time.Time
ArtistID int32 ArtistID int32
} }
type ListenActivityForArtistRow struct { type ListenActivityForArtistRow struct {
BucketStart time.Time Day pgtype.Date
ListenCount int64 ListenCount int64
} }
func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivityForArtistParams) ([]ListenActivityForArtistRow, error) { func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivityForArtistParams) ([]ListenActivityForArtistRow, error) {
rows, err := q.db.Query(ctx, listenActivityForArtist, rows, err := q.db.Query(ctx, listenActivityForArtist,
arg.Column1, arg.Column1,
arg.Column2, arg.ListenedAt,
arg.Column3, arg.ListenedAt_2,
arg.ArtistID, arg.ArtistID,
) )
if err != nil { if err != nil {
@ -773,7 +776,7 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit
var items []ListenActivityForArtistRow var items []ListenActivityForArtistRow
for rows.Next() { for rows.Next() {
var i ListenActivityForArtistRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@ -785,46 +788,35 @@ func (q *Queries) ListenActivityForArtist(ctx context.Context, arg ListenActivit
} }
const listenActivityForRelease = `-- name: ListenActivityForRelease :many const listenActivityForRelease = `-- name: ListenActivityForRelease :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.track_id, l.listened_at, l.client, l.user_id JOIN tracks t ON l.track_id = t.id
FROM listens l WHERE l.listened_at >= $2
JOIN tracks t ON l.track_id = t.id AND l.listened_at < $3
WHERE t.release_id = $4 AND t.release_id = $4
), GROUP BY day
bucketed_listens AS ( ORDER BY day
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
` `
type ListenActivityForReleaseParams struct { type ListenActivityForReleaseParams struct {
Column1 time.Time Column1 string
Column2 time.Time ListenedAt time.Time
Column3 pgtype.Interval ListenedAt_2 time.Time
ReleaseID int32 ReleaseID int32
} }
type ListenActivityForReleaseRow struct { type ListenActivityForReleaseRow struct {
BucketStart time.Time Day pgtype.Date
ListenCount int64 ListenCount int64
} }
func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivityForReleaseParams) ([]ListenActivityForReleaseRow, error) { func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivityForReleaseParams) ([]ListenActivityForReleaseRow, error) {
rows, err := q.db.Query(ctx, listenActivityForRelease, rows, err := q.db.Query(ctx, listenActivityForRelease,
arg.Column1, arg.Column1,
arg.Column2, arg.ListenedAt,
arg.Column3, arg.ListenedAt_2,
arg.ReleaseID, arg.ReleaseID,
) )
if err != nil { if err != nil {
@ -834,7 +826,7 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi
var items []ListenActivityForReleaseRow var items []ListenActivityForReleaseRow
for rows.Next() { for rows.Next() {
var i ListenActivityForReleaseRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@ -846,46 +838,35 @@ func (q *Queries) ListenActivityForRelease(ctx context.Context, arg ListenActivi
} }
const listenActivityForTrack = `-- name: ListenActivityForTrack :many const listenActivityForTrack = `-- name: ListenActivityForTrack :many
WITH buckets AS ( SELECT
SELECT generate_series($1::timestamptz, $2::timestamptz, $3::interval) AS bucket_start (listened_at AT TIME ZONE $1::text)::date as day,
), COUNT(*) AS listen_count
filtered_listens AS ( FROM listens l
SELECT l.track_id, l.listened_at, l.client, l.user_id JOIN tracks t ON l.track_id = t.id
FROM listens l WHERE l.listened_at >= $2
JOIN tracks t ON l.track_id = t.id AND l.listened_at < $3
WHERE t.id = $4 AND t.id = $4
), GROUP BY day
bucketed_listens AS ( ORDER BY day
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
` `
type ListenActivityForTrackParams struct { type ListenActivityForTrackParams struct {
Column1 time.Time Column1 string
Column2 time.Time ListenedAt time.Time
Column3 pgtype.Interval ListenedAt_2 time.Time
ID int32 ID int32
} }
type ListenActivityForTrackRow struct { type ListenActivityForTrackRow struct {
BucketStart time.Time Day pgtype.Date
ListenCount int64 ListenCount int64
} }
func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivityForTrackParams) ([]ListenActivityForTrackRow, error) { func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivityForTrackParams) ([]ListenActivityForTrackRow, error) {
rows, err := q.db.Query(ctx, listenActivityForTrack, rows, err := q.db.Query(ctx, listenActivityForTrack,
arg.Column1, arg.Column1,
arg.Column2, arg.ListenedAt,
arg.Column3, arg.ListenedAt_2,
arg.ID, arg.ID,
) )
if err != nil { if err != nil {
@ -895,7 +876,7 @@ func (q *Queries) ListenActivityForTrack(ctx context.Context, arg ListenActivity
var items []ListenActivityForTrackRow var items []ListenActivityForTrackRow
for rows.Next() { for rows.Next() {
var i ListenActivityForTrackRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)

View file

@ -127,6 +127,12 @@ func DateRange(week, month, year int) (time.Time, time.Time, error) {
return start, end, nil 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 // 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 // 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. // between the two files. If that fail, copy the file contents from src to dst.