mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
feat: interest over time graph (#127)
* api * ui * test * add margin to prevent clipping
This commit is contained in:
parent
e45099c71a
commit
231eb1b0fb
16 changed files with 1097 additions and 4 deletions
|
|
@ -23,6 +23,12 @@ interface timeframe {
|
||||||
to?: number;
|
to?: number;
|
||||||
period?: string;
|
period?: string;
|
||||||
}
|
}
|
||||||
|
interface getInterestArgs {
|
||||||
|
buckets: number;
|
||||||
|
artist_id: number;
|
||||||
|
album_id: number;
|
||||||
|
track_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleJson<T>(r: Response): Promise<T> {
|
async function handleJson<T>(r: Response): Promise<T> {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|
@ -79,6 +85,13 @@ async function getActivity(
|
||||||
return handleJson<ListenActivityItem[]>(r);
|
return handleJson<ListenActivityItem[]>(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getInterest(args: getInterestArgs): Promise<InterestBucket[]> {
|
||||||
|
const r = await fetch(
|
||||||
|
`/apis/web/v1/interest?buckets=${args.buckets}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`
|
||||||
|
);
|
||||||
|
return handleJson<InterestBucket[]>(r);
|
||||||
|
}
|
||||||
|
|
||||||
async function getStats(period: string): Promise<Stats> {
|
async function getStats(period: string): Promise<Stats> {
|
||||||
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
|
const r = await fetch(`/apis/web/v1/stats?period=${period}`);
|
||||||
|
|
||||||
|
|
@ -315,6 +328,7 @@ export {
|
||||||
getTopAlbums,
|
getTopAlbums,
|
||||||
getTopArtists,
|
getTopArtists,
|
||||||
getActivity,
|
getActivity,
|
||||||
|
getInterest,
|
||||||
getStats,
|
getStats,
|
||||||
search,
|
search,
|
||||||
replaceImage,
|
replaceImage,
|
||||||
|
|
@ -397,6 +411,11 @@ type ListenActivityItem = {
|
||||||
start_time: Date;
|
start_time: Date;
|
||||||
listens: number;
|
listens: number;
|
||||||
};
|
};
|
||||||
|
type InterestBucket = {
|
||||||
|
bucket_start: Date;
|
||||||
|
bucket_end: Date;
|
||||||
|
listen_count: number;
|
||||||
|
};
|
||||||
type SimpleArtists = {
|
type SimpleArtists = {
|
||||||
name: string;
|
name: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -454,6 +473,7 @@ type RewindStats = {
|
||||||
export type {
|
export type {
|
||||||
getItemsArgs,
|
getItemsArgs,
|
||||||
getActivityArgs,
|
getActivityArgs,
|
||||||
|
getInterestArgs,
|
||||||
Track,
|
Track,
|
||||||
Artist,
|
Artist,
|
||||||
Album,
|
Album,
|
||||||
|
|
@ -461,6 +481,7 @@ export type {
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
ListenActivityItem,
|
ListenActivityItem,
|
||||||
|
InterestBucket,
|
||||||
User,
|
User,
|
||||||
Alias,
|
Alias,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
|
|
|
||||||
117
client/app/components/InterestGraph.tsx
Normal file
117
client/app/components/InterestGraph.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getActivity,
|
||||||
|
getInterest,
|
||||||
|
type getActivityArgs,
|
||||||
|
type getInterestArgs,
|
||||||
|
type ListenActivityItem,
|
||||||
|
} from "api/api";
|
||||||
|
import Popup from "./Popup";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTheme } from "~/hooks/useTheme";
|
||||||
|
import ActivityOptsSelector from "./ActivityOptsSelector";
|
||||||
|
import type { Theme } from "~/styles/themes.css";
|
||||||
|
import { Area, AreaChart, Line, LineChart, Tooltip } from "recharts";
|
||||||
|
import { RechartsDevtools } from "@recharts/devtools";
|
||||||
|
|
||||||
|
function getPrimaryColor(theme: Theme): string {
|
||||||
|
const value = theme.primary;
|
||||||
|
const rgbMatch = value.match(
|
||||||
|
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||||
|
);
|
||||||
|
if (rgbMatch) {
|
||||||
|
const [, r, g, b] = rgbMatch.map(Number);
|
||||||
|
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
buckets?: number;
|
||||||
|
artistId?: number;
|
||||||
|
albumId?: number;
|
||||||
|
trackId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InterestGraph({
|
||||||
|
buckets = 14,
|
||||||
|
artistId = 0,
|
||||||
|
albumId = 0,
|
||||||
|
trackId = 0,
|
||||||
|
}: Props) {
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"interest",
|
||||||
|
{
|
||||||
|
buckets: buckets,
|
||||||
|
artist_id: artistId,
|
||||||
|
album_id: albumId,
|
||||||
|
track_id: trackId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: ({ queryKey }) => getInterest(queryKey[1] as getInterestArgs),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const color = getPrimaryColor(theme);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="w-[500px]">
|
||||||
|
<h3>Interest over time</h3>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="w-[500px]">
|
||||||
|
<h3>Interest over time</h3>
|
||||||
|
<p className="error">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start w-full max-w-[500px]">
|
||||||
|
<h3>Interest over time</h3>
|
||||||
|
<AreaChart
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: 3.5,
|
||||||
|
maxWidth: 440,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
margin={{ top: 5, right: 0, left: 0, bottom: 10 }}
|
||||||
|
data={data}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.5} />
|
||||||
|
<stop offset="85%" stopColor={color} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
dataKey="listen_count"
|
||||||
|
type="natural"
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#colorGradient)"
|
||||||
|
animationDuration={750}
|
||||||
|
animationEasing="ease-in-out"
|
||||||
|
activeDot={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="listen_count"
|
||||||
|
type="natural"
|
||||||
|
stroke={color}
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={2}
|
||||||
|
animationDuration={750}
|
||||||
|
animationEasing="ease-in-out"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
style={{ filter: `drop-shadow(0px 0px 5px ${color})` }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
import { timeListenedString } from "~/utils/utils";
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
import InterestGraph from "~/components/InterestGraph";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||||
|
|
@ -69,7 +70,10 @@ export default function Album() {
|
||||||
<div className="flex flex-wrap gap-20 mt-10">
|
<div className="flex flex-wrap gap-20 mt-10">
|
||||||
<LastPlays limit={30} albumId={album.id} />
|
<LastPlays limit={30} albumId={album.id} />
|
||||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||||
<ActivityGrid configurable albumId={album.id} />
|
<div className="flex flex-col items-start gap-4">
|
||||||
|
<ActivityGrid configurable albumId={album.id} />
|
||||||
|
<InterestGraph albumId={album.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MediaLayout>
|
</MediaLayout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import MediaLayout from "./MediaLayout";
|
||||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
import { timeListenedString } from "~/utils/utils";
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
import InterestGraph from "~/components/InterestGraph";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||||
|
|
@ -76,7 +77,10 @@ export default function Artist() {
|
||||||
<div className="flex gap-15 mt-10 flex-wrap">
|
<div className="flex gap-15 mt-10 flex-wrap">
|
||||||
<LastPlays limit={20} artistId={artist.id} />
|
<LastPlays limit={20} artistId={artist.id} />
|
||||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||||
<ActivityGrid configurable artistId={artist.id} />
|
<div className="flex flex-col items-start gap-4">
|
||||||
|
<ActivityGrid configurable artistId={artist.id} />
|
||||||
|
<InterestGraph artistId={artist.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import PeriodSelector from "~/components/PeriodSelector";
|
||||||
import MediaLayout from "./MediaLayout";
|
import MediaLayout from "./MediaLayout";
|
||||||
import ActivityGrid from "~/components/ActivityGrid";
|
import ActivityGrid from "~/components/ActivityGrid";
|
||||||
import { timeListenedString } from "~/utils/utils";
|
import { timeListenedString } from "~/utils/utils";
|
||||||
|
import InterestGraph from "~/components/InterestGraph";
|
||||||
|
|
||||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||||
|
|
@ -73,7 +74,10 @@ export default function Track() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-20 mt-10">
|
<div className="flex flex-wrap gap-20 mt-10">
|
||||||
<LastPlays limit={20} trackId={track.id} />
|
<LastPlays limit={20} trackId={track.id} />
|
||||||
<ActivityGrid trackId={track.id} configurable />
|
<div className="flex flex-col items-start gap-4">
|
||||||
|
<ActivityGrid configurable trackId={track.id} />
|
||||||
|
<InterestGraph trackId={track.id} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MediaLayout>
|
</MediaLayout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@react-router/node": "^7.5.3",
|
"@react-router/node": "^7.5.3",
|
||||||
"@react-router/serve": "^7.5.3",
|
"@react-router/serve": "^7.5.3",
|
||||||
|
"@recharts/devtools": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"@vanilla-extract/css": "^1.17.4",
|
"@vanilla-extract/css": "^1.17.4",
|
||||||
"color.js": "^1.2.0",
|
"color.js": "^1.2.0",
|
||||||
|
|
@ -20,7 +21,9 @@
|
||||||
"lucide-react": "^0.513.0",
|
"lucide-react": "^0.513.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.5.3"
|
"react-is": "^19.2.3",
|
||||||
|
"react-router": "^7.5.3",
|
||||||
|
"recharts": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.5.3",
|
"@react-router/dev": "^7.5.3",
|
||||||
|
|
|
||||||
264
client/yarn.lock
264
client/yarn.lock
|
|
@ -689,6 +689,23 @@
|
||||||
morgan "^1.10.0"
|
morgan "^1.10.0"
|
||||||
source-map-support "^0.5.21"
|
source-map-support "^0.5.21"
|
||||||
|
|
||||||
|
"@recharts/devtools@^0.0.7":
|
||||||
|
version "0.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@recharts/devtools/-/devtools-0.0.7.tgz#a909d102efd76fc45bc2b7a150e67a02da04b4c1"
|
||||||
|
integrity sha512-ud66rUf3FYf1yQLGSCowI50EQyC/rcZblvDgNvfUIVaEXyQtr5K2DFgwegziqbVclsVBQLTxyntVViJN5H4oWQ==
|
||||||
|
|
||||||
|
"@reduxjs/toolkit@1.x.x || 2.x.x":
|
||||||
|
version "2.11.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82"
|
||||||
|
integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==
|
||||||
|
dependencies:
|
||||||
|
"@standard-schema/spec" "^1.0.0"
|
||||||
|
"@standard-schema/utils" "^0.3.0"
|
||||||
|
immer "^11.0.0"
|
||||||
|
redux "^5.0.1"
|
||||||
|
redux-thunk "^3.1.0"
|
||||||
|
reselect "^5.1.0"
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.42.0":
|
"@rollup/rollup-android-arm-eabi@4.42.0":
|
||||||
version "4.42.0"
|
version "4.42.0"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz#8baae15a6a27f18b7c5be420e00ab08c7d3dd6f4"
|
||||||
|
|
@ -789,6 +806,16 @@
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz#516c6770ba15fe6aef369d217a9747492c01e8b7"
|
||||||
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
|
integrity sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==
|
||||||
|
|
||||||
|
"@standard-schema/spec@^1.0.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
|
||||||
|
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
|
||||||
|
|
||||||
|
"@standard-schema/utils@^0.3.0":
|
||||||
|
version "0.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||||
|
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||||
|
|
||||||
"@tailwindcss/node@4.1.8":
|
"@tailwindcss/node@4.1.8":
|
||||||
version "4.1.8"
|
version "4.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.8.tgz#e29187abec6194ce1e9f072208c62116a79a129b"
|
||||||
|
|
@ -918,6 +945,57 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
|
"@types/d3-array@^3.0.3":
|
||||||
|
version "3.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c"
|
||||||
|
integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==
|
||||||
|
|
||||||
|
"@types/d3-color@*":
|
||||||
|
version "3.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
|
||||||
|
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
|
||||||
|
|
||||||
|
"@types/d3-ease@^3.0.0":
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
|
||||||
|
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
|
||||||
|
|
||||||
|
"@types/d3-interpolate@^3.0.1":
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||||
|
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-color" "*"
|
||||||
|
|
||||||
|
"@types/d3-path@*":
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a"
|
||||||
|
integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==
|
||||||
|
|
||||||
|
"@types/d3-scale@^4.0.2":
|
||||||
|
version "4.0.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb"
|
||||||
|
integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-time" "*"
|
||||||
|
|
||||||
|
"@types/d3-shape@^3.1.0":
|
||||||
|
version "3.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3"
|
||||||
|
integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-path" "*"
|
||||||
|
|
||||||
|
"@types/d3-time@*", "@types/d3-time@^3.0.0":
|
||||||
|
version "3.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f"
|
||||||
|
integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==
|
||||||
|
|
||||||
|
"@types/d3-timer@^3.0.0":
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
|
||||||
|
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
|
||||||
|
|
||||||
"@types/estree@1.0.7":
|
"@types/estree@1.0.7":
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||||
|
|
@ -949,6 +1027,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
|
"@types/use-sync-external-store@^0.0.6":
|
||||||
|
version "0.0.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
|
||||||
|
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
|
||||||
|
|
||||||
"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
|
"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
|
resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
|
||||||
|
|
@ -1163,6 +1246,11 @@ chownr@^3.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
|
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
|
||||||
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
|
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
|
||||||
|
|
||||||
|
clsx@^2.1.1:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||||
|
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||||
|
|
||||||
color-convert@^2.0.1:
|
color-convert@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||||
|
|
@ -1261,6 +1349,77 @@ csstype@^3.0.2, csstype@^3.0.7:
|
||||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||||
|
|
||||||
|
"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
|
||||||
|
version "3.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
|
||||||
|
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
|
||||||
|
dependencies:
|
||||||
|
internmap "1 - 2"
|
||||||
|
|
||||||
|
"d3-color@1 - 3":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
|
||||||
|
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
|
||||||
|
|
||||||
|
d3-ease@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
|
||||||
|
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||||
|
|
||||||
|
"d3-format@1 - 3":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
|
||||||
|
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
|
||||||
|
|
||||||
|
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||||
|
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||||
|
dependencies:
|
||||||
|
d3-color "1 - 3"
|
||||||
|
|
||||||
|
d3-path@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
|
||||||
|
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
|
||||||
|
|
||||||
|
d3-scale@^4.0.2:
|
||||||
|
version "4.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
|
||||||
|
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
|
||||||
|
dependencies:
|
||||||
|
d3-array "2.10.0 - 3"
|
||||||
|
d3-format "1 - 3"
|
||||||
|
d3-interpolate "1.2.0 - 3"
|
||||||
|
d3-time "2.1.1 - 3"
|
||||||
|
d3-time-format "2 - 4"
|
||||||
|
|
||||||
|
d3-shape@^3.1.0:
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
|
||||||
|
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
|
||||||
|
dependencies:
|
||||||
|
d3-path "^3.1.0"
|
||||||
|
|
||||||
|
"d3-time-format@2 - 4":
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
|
||||||
|
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
|
||||||
|
dependencies:
|
||||||
|
d3-time "1 - 3"
|
||||||
|
|
||||||
|
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
|
||||||
|
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
|
||||||
|
dependencies:
|
||||||
|
d3-array "2 - 3"
|
||||||
|
|
||||||
|
d3-timer@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
||||||
|
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
||||||
|
|
||||||
debug@2.6.9:
|
debug@2.6.9:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
|
|
@ -1275,6 +1434,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms "^2.1.3"
|
ms "^2.1.3"
|
||||||
|
|
||||||
|
decimal.js-light@^2.5.1:
|
||||||
|
version "2.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
|
||||||
|
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
|
||||||
|
|
||||||
dedent@^1.5.3:
|
dedent@^1.5.3:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
|
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
|
||||||
|
|
@ -1384,6 +1548,11 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
|
|
||||||
|
es-toolkit@^1.39.3:
|
||||||
|
version "1.43.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.43.0.tgz#2c278d55ffeb30421e6e73a009738ed37b10ef61"
|
||||||
|
integrity sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==
|
||||||
|
|
||||||
esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
|
esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
|
||||||
version "0.25.5"
|
version "0.25.5"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
||||||
|
|
@ -1438,6 +1607,11 @@ eval@0.1.8:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
require-like ">= 0.1.1"
|
require-like ">= 0.1.1"
|
||||||
|
|
||||||
|
eventemitter3@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4"
|
||||||
|
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
|
||||||
|
|
||||||
exit-hook@2.2.1:
|
exit-hook@2.2.1:
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
|
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
|
||||||
|
|
@ -1646,11 +1820,26 @@ iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer ">= 2.1.2 < 3"
|
safer-buffer ">= 2.1.2 < 3"
|
||||||
|
|
||||||
|
immer@^10.1.1:
|
||||||
|
version "10.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1"
|
||||||
|
integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==
|
||||||
|
|
||||||
|
immer@^11.0.0:
|
||||||
|
version "11.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
|
||||||
|
integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
|
||||||
|
|
||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||||
|
|
||||||
|
"internmap@1 - 2":
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
|
||||||
|
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
version "1.9.1"
|
version "1.9.1"
|
||||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||||
|
|
@ -2180,6 +2369,19 @@ react-dom@^19.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
scheduler "^0.26.0"
|
scheduler "^0.26.0"
|
||||||
|
|
||||||
|
react-is@^19.2.3:
|
||||||
|
version "19.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29"
|
||||||
|
integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==
|
||||||
|
|
||||||
|
"react-redux@8.x.x || 9.x.x":
|
||||||
|
version "9.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"
|
||||||
|
integrity sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==
|
||||||
|
dependencies:
|
||||||
|
"@types/use-sync-external-store" "^0.0.6"
|
||||||
|
use-sync-external-store "^1.4.0"
|
||||||
|
|
||||||
react-refresh@^0.14.0:
|
react-refresh@^0.14.0:
|
||||||
version "0.14.2"
|
version "0.14.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9"
|
||||||
|
|
@ -2203,11 +2405,43 @@ readdirp@^4.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||||
|
|
||||||
|
recharts@^3.6.0:
|
||||||
|
version "3.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.6.0.tgz#403f0606581153601857e46733277d1411633df3"
|
||||||
|
integrity sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==
|
||||||
|
dependencies:
|
||||||
|
"@reduxjs/toolkit" "1.x.x || 2.x.x"
|
||||||
|
clsx "^2.1.1"
|
||||||
|
decimal.js-light "^2.5.1"
|
||||||
|
es-toolkit "^1.39.3"
|
||||||
|
eventemitter3 "^5.0.1"
|
||||||
|
immer "^10.1.1"
|
||||||
|
react-redux "8.x.x || 9.x.x"
|
||||||
|
reselect "5.1.1"
|
||||||
|
tiny-invariant "^1.3.3"
|
||||||
|
use-sync-external-store "^1.2.2"
|
||||||
|
victory-vendor "^37.0.2"
|
||||||
|
|
||||||
|
redux-thunk@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
|
||||||
|
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
|
||||||
|
|
||||||
|
redux@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
|
||||||
|
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
|
||||||
|
|
||||||
"require-like@>= 0.1.1":
|
"require-like@>= 0.1.1":
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
|
resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
|
||||||
integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
|
integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
|
||||||
|
|
||||||
|
reselect@5.1.1, reselect@^5.1.0:
|
||||||
|
version "5.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e"
|
||||||
|
integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==
|
||||||
|
|
||||||
retry@^0.12.0:
|
retry@^0.12.0:
|
||||||
version "0.12.0"
|
version "0.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
||||||
|
|
@ -2492,6 +2726,11 @@ tar@^7.4.3:
|
||||||
mkdirp "^3.0.1"
|
mkdirp "^3.0.1"
|
||||||
yallist "^5.0.0"
|
yallist "^5.0.0"
|
||||||
|
|
||||||
|
tiny-invariant@^1.3.3:
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||||
|
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
|
||||||
|
|
||||||
tinyglobby@^0.2.13:
|
tinyglobby@^0.2.13:
|
||||||
version "0.2.14"
|
version "0.2.14"
|
||||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
|
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d"
|
||||||
|
|
@ -2566,6 +2805,11 @@ update-browserslist-db@^1.1.3:
|
||||||
escalade "^3.2.0"
|
escalade "^3.2.0"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
|
|
||||||
|
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||||
|
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||||
|
|
||||||
utils-merge@1.0.1:
|
utils-merge@1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||||
|
|
@ -2594,6 +2838,26 @@ vary@~1.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||||
|
|
||||||
|
victory-vendor@^37.0.2:
|
||||||
|
version "37.3.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da"
|
||||||
|
integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-array" "^3.0.3"
|
||||||
|
"@types/d3-ease" "^3.0.0"
|
||||||
|
"@types/d3-interpolate" "^3.0.1"
|
||||||
|
"@types/d3-scale" "^4.0.2"
|
||||||
|
"@types/d3-shape" "^3.1.0"
|
||||||
|
"@types/d3-time" "^3.0.0"
|
||||||
|
"@types/d3-timer" "^3.0.0"
|
||||||
|
d3-array "^3.1.6"
|
||||||
|
d3-ease "^3.0.1"
|
||||||
|
d3-interpolate "^3.0.1"
|
||||||
|
d3-scale "^4.0.2"
|
||||||
|
d3-shape "^3.1.0"
|
||||||
|
d3-time "^3.0.0"
|
||||||
|
d3-timer "^3.0.1"
|
||||||
|
|
||||||
vite-node@^3.1.4, vite-node@^3.2.2:
|
vite-node@^3.1.4, vite-node@^3.2.2:
|
||||||
version "3.2.3"
|
version "3.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
|
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
|
||||||
|
|
|
||||||
162
db/queries/interest.sql
Normal file
162
db/queries/interest.sql
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
-- name: GetGroupedListensFromArtist :many
|
||||||
|
WITH artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.listened_at
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
JOIN artist_tracks at ON at.track_id = t.id
|
||||||
|
WHERE at.artist_id = $1
|
||||||
|
),
|
||||||
|
bounds AS (
|
||||||
|
SELECT
|
||||||
|
MIN(listened_at) AS start_time,
|
||||||
|
MAX(listened_at) AS end_time
|
||||||
|
FROM artist_listens
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
sqlc.arg(bucket_count) - 1,
|
||||||
|
FLOOR(
|
||||||
|
(
|
||||||
|
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
|
||||||
|
/
|
||||||
|
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
|
||||||
|
) * sqlc.arg(bucket_count)
|
||||||
|
)::int
|
||||||
|
) AS bucket_idx,
|
||||||
|
b.start_time,
|
||||||
|
b.end_time
|
||||||
|
FROM artist_listens al
|
||||||
|
CROSS JOIN bounds b
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
bucket_idx * (end_time - start_time)
|
||||||
|
/ sqlc.arg(bucket_count)
|
||||||
|
) AS bucket_start,
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
(bucket_idx + 1) * (end_time - start_time)
|
||||||
|
/ sqlc.arg(bucket_count)
|
||||||
|
) AS bucket_end,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_idx, start_time, end_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start::timestamptz,
|
||||||
|
bucket_end::timestamptz,
|
||||||
|
listen_count
|
||||||
|
FROM aggregated
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
|
||||||
|
-- name: GetGroupedListensFromRelease :many
|
||||||
|
WITH artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.listened_at
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
WHERE t.release_id = $1
|
||||||
|
),
|
||||||
|
bounds AS (
|
||||||
|
SELECT
|
||||||
|
MIN(listened_at) AS start_time,
|
||||||
|
MAX(listened_at) AS end_time
|
||||||
|
FROM artist_listens
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
sqlc.arg(bucket_count) - 1,
|
||||||
|
FLOOR(
|
||||||
|
(
|
||||||
|
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
|
||||||
|
/
|
||||||
|
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
|
||||||
|
) * sqlc.arg(bucket_count)
|
||||||
|
)::int
|
||||||
|
) AS bucket_idx,
|
||||||
|
b.start_time,
|
||||||
|
b.end_time
|
||||||
|
FROM artist_listens al
|
||||||
|
CROSS JOIN bounds b
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
bucket_idx * (end_time - start_time)
|
||||||
|
/ sqlc.arg(bucket_count)
|
||||||
|
) AS bucket_start,
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
(bucket_idx + 1) * (end_time - start_time)
|
||||||
|
/ sqlc.arg(bucket_count)
|
||||||
|
) AS bucket_end,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_idx, start_time, end_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start::timestamptz,
|
||||||
|
bucket_end::timestamptz,
|
||||||
|
listen_count
|
||||||
|
FROM aggregated
|
||||||
|
ORDER BY bucket_start;
|
||||||
|
|
||||||
|
-- name: GetGroupedListensFromTrack :many
|
||||||
|
WITH artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.listened_at
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
WHERE t.id = $1
|
||||||
|
),
|
||||||
|
bounds AS (
|
||||||
|
SELECT
|
||||||
|
MIN(listened_at) AS start_time,
|
||||||
|
MAX(listened_at) AS end_time
|
||||||
|
FROM artist_listens
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
sqlc.arg(bucket_count) - 1,
|
||||||
|
FLOOR(
|
||||||
|
(
|
||||||
|
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
|
||||||
|
/
|
||||||
|
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
|
||||||
|
) * sqlc.arg(bucket_count)
|
||||||
|
)::int
|
||||||
|
) AS bucket_idx,
|
||||||
|
b.start_time,
|
||||||
|
b.end_time
|
||||||
|
FROM artist_listens al
|
||||||
|
CROSS JOIN bounds b
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
bucket_idx * (end_time - start_time)
|
||||||
|
/ sqlc.arg(bucket_count)
|
||||||
|
) AS bucket_start,
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
(bucket_idx + 1) * (end_time - start_time)
|
||||||
|
/ sqlc.arg(bucket_count)
|
||||||
|
) AS bucket_end,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_idx, start_time, end_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start::timestamptz,
|
||||||
|
bucket_end::timestamptz,
|
||||||
|
listen_count
|
||||||
|
FROM aggregated
|
||||||
|
ORDER BY bucket_start;
|
||||||
47
engine/handlers/interest.go
Normal file
47
engine/handlers/interest.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/logger"
|
||||||
|
"github.com/gabehf/koito/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetInterestHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
l := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
l.Debug().Msg("GetInterestHandler: Received request to retrieve interest")
|
||||||
|
|
||||||
|
// im just using this to parse the artist/album/track id, which is bad
|
||||||
|
parsed := OptsFromRequest(r)
|
||||||
|
|
||||||
|
bucketCountStr := r.URL.Query().Get("buckets")
|
||||||
|
var buckets = 0
|
||||||
|
var err error
|
||||||
|
if buckets, err = strconv.Atoi(bucketCountStr); err != nil {
|
||||||
|
l.Debug().Msg("GetInterestHandler: Buckets is not an integer")
|
||||||
|
utils.WriteError(w, "parameter 'buckets' must be an integer", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := db.GetInterestOpts{
|
||||||
|
Buckets: buckets,
|
||||||
|
AlbumID: int32(parsed.AlbumID),
|
||||||
|
ArtistID: int32(parsed.ArtistID),
|
||||||
|
TrackID: int32(parsed.TrackID),
|
||||||
|
}
|
||||||
|
|
||||||
|
interest, err := store.GetInterest(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
l.Err(err).Msg("GetInterestHandler: Failed to query interest")
|
||||||
|
utils.WriteError(w, "Failed to retrieve interest: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, interest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,7 @@ func bindRoutes(
|
||||||
r.Get("/search", handlers.SearchHandler(db))
|
r.Get("/search", handlers.SearchHandler(db))
|
||||||
r.Get("/aliases", handlers.GetAliasesHandler(db))
|
r.Get("/aliases", handlers.GetAliasesHandler(db))
|
||||||
r.Get("/summary", handlers.SummaryHandler(db))
|
r.Get("/summary", handlers.SummaryHandler(db))
|
||||||
|
r.Get("/interest", handlers.GetInterestHandler(db))
|
||||||
})
|
})
|
||||||
r.Post("/logout", handlers.LogoutHandler(db))
|
r.Post("/logout", handlers.LogoutHandler(db))
|
||||||
if !cfg.RateLimitDisabled() {
|
if !cfg.RateLimitDisabled() {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type DB interface {
|
||||||
GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*models.User, error)
|
GetUserBySession(ctx context.Context, sessionId uuid.UUID) (*models.User, error)
|
||||||
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
|
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
|
||||||
GetUserByApiKey(ctx context.Context, key string) (*models.User, error)
|
GetUserByApiKey(ctx context.Context, key string) (*models.User, error)
|
||||||
|
GetInterest(ctx context.Context, opts GetInterestOpts) ([]InterestBucket, error)
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,3 +153,10 @@ type GetExportPageOpts struct {
|
||||||
TrackID int32
|
TrackID int32
|
||||||
Limit int32
|
Limit int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetInterestOpts struct {
|
||||||
|
Buckets int
|
||||||
|
AlbumID int32
|
||||||
|
ArtistID int32
|
||||||
|
TrackID int32
|
||||||
|
}
|
||||||
|
|
|
||||||
70
internal/db/psql/interest.go
Normal file
70
internal/db/psql/interest.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package psql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/gabehf/koito/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (d *Psql) GetInterest(ctx context.Context, opts db.GetInterestOpts) ([]db.InterestBucket, error) {
|
||||||
|
if opts.Buckets == 0 {
|
||||||
|
return nil, errors.New("GetInterest: bucket count must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := make([]db.InterestBucket, opts.Buckets)
|
||||||
|
|
||||||
|
if opts.ArtistID != 0 {
|
||||||
|
resp, err := d.q.GetGroupedListensFromArtist(ctx, repository.GetGroupedListensFromArtistParams{
|
||||||
|
ArtistID: opts.ArtistID,
|
||||||
|
BucketCount: opts.Buckets,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromArtist: %w", err)
|
||||||
|
}
|
||||||
|
for i, v := range resp {
|
||||||
|
ret[i] = db.InterestBucket{
|
||||||
|
BucketStart: v.BucketStart,
|
||||||
|
BucketEnd: v.BucketEnd,
|
||||||
|
ListenCount: v.ListenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
} else if opts.AlbumID != 0 {
|
||||||
|
resp, err := d.q.GetGroupedListensFromRelease(ctx, repository.GetGroupedListensFromReleaseParams{
|
||||||
|
ReleaseID: opts.AlbumID,
|
||||||
|
BucketCount: opts.Buckets,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromRelease: %w", err)
|
||||||
|
}
|
||||||
|
for i, v := range resp {
|
||||||
|
ret[i] = db.InterestBucket{
|
||||||
|
BucketStart: v.BucketStart,
|
||||||
|
BucketEnd: v.BucketEnd,
|
||||||
|
ListenCount: v.ListenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
} else if opts.TrackID != 0 {
|
||||||
|
resp, err := d.q.GetGroupedListensFromTrack(ctx, repository.GetGroupedListensFromTrackParams{
|
||||||
|
ID: opts.TrackID,
|
||||||
|
BucketCount: opts.Buckets,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetInterest: GetGroupedListensFromTrack: %w", err)
|
||||||
|
}
|
||||||
|
for i, v := range resp {
|
||||||
|
ret[i] = db.InterestBucket{
|
||||||
|
BucketStart: v.BucketStart,
|
||||||
|
BucketEnd: v.BucketEnd,
|
||||||
|
ListenCount: v.ListenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret, nil
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("GetInterest: artist id, album id, or track id must be provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/db/psql/interest_test.go
Normal file
112
internal/db/psql/interest_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package psql_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gabehf/koito/internal/db"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// an llm wrote this because i didn't feel like it. it looks like it works, although
|
||||||
|
// it could stand to be more thorough
|
||||||
|
func TestGetInterest(t *testing.T) {
|
||||||
|
truncateTestData(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// --- Setup Data ---
|
||||||
|
|
||||||
|
// Insert Artists
|
||||||
|
err := store.Exec(ctx, `
|
||||||
|
INSERT INTO artists (musicbrainz_id)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000001'),
|
||||||
|
('00000000-0000-0000-0000-000000000002')`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Insert Releases (Albums)
|
||||||
|
err = store.Exec(ctx, `
|
||||||
|
INSERT INTO releases (musicbrainz_id)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000011')`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Insert Tracks (Both on Release 1)
|
||||||
|
err = store.Exec(ctx, `
|
||||||
|
INSERT INTO tracks (musicbrainz_id, release_id)
|
||||||
|
VALUES ('11111111-1111-1111-1111-111111111111', 1),
|
||||||
|
('22222222-2222-2222-2222-222222222222', 1)`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Link Artists to Tracks
|
||||||
|
// Artist 1 -> Track 1
|
||||||
|
// Artist 2 -> Track 2
|
||||||
|
err = store.Exec(ctx, `
|
||||||
|
INSERT INTO artist_tracks (artist_id, track_id)
|
||||||
|
VALUES (1, 1), (2, 2)`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Insert Listens
|
||||||
|
// Track 1 (Artist 1, Release 1): 3 Listens
|
||||||
|
// Track 2 (Artist 2, Release 1): 2 Listens
|
||||||
|
err = store.Exec(ctx, `
|
||||||
|
INSERT INTO listens (user_id, track_id, listened_at) VALUES
|
||||||
|
(1, 1, NOW() - INTERVAL '1 hour'),
|
||||||
|
(1, 1, NOW() - INTERVAL '2 hours'),
|
||||||
|
(1, 1, NOW() - INTERVAL '3 hours'),
|
||||||
|
(1, 2, NOW() - INTERVAL '1 hour'),
|
||||||
|
(1, 2, NOW() - INTERVAL '2 hours')
|
||||||
|
`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// --- Test Validation ---
|
||||||
|
|
||||||
|
t.Run("Validation", func(t *testing.T) {
|
||||||
|
// Error: Missing Buckets
|
||||||
|
_, err := store.GetInterest(ctx, db.GetInterestOpts{ArtistID: 1})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "bucket count must be provided")
|
||||||
|
|
||||||
|
// Error: Missing ID
|
||||||
|
_, err = store.GetInterest(ctx, db.GetInterestOpts{Buckets: 10})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "must be provided")
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Test Data Retrieval ---
|
||||||
|
// Note: We use Buckets: 1 to ensure all listens are aggregated into a single result
|
||||||
|
// for easier assertion, avoiding complex date/time math in the test.
|
||||||
|
|
||||||
|
t.Run("Artist Interest", func(t *testing.T) {
|
||||||
|
// Artist 1 should have 3 listens (from Track 1)
|
||||||
|
buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
|
||||||
|
ArtistID: 1,
|
||||||
|
Buckets: 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, buckets, 1)
|
||||||
|
assert.EqualValues(t, 3, buckets[0].ListenCount, "Artist 1 should have 3 listens")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Album Interest", func(t *testing.T) {
|
||||||
|
// Album 1 contains Track 1 (3 listens) and Track 2 (2 listens) = 5 Total
|
||||||
|
buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
|
||||||
|
AlbumID: 1,
|
||||||
|
Buckets: 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, buckets, 1)
|
||||||
|
assert.EqualValues(t, 5, buckets[0].ListenCount, "Album 1 should have 5 listens total")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Track Interest", func(t *testing.T) {
|
||||||
|
// Track 2 should have 2 listens
|
||||||
|
buckets, err := store.GetInterest(ctx, db.GetInterestOpts{
|
||||||
|
TrackID: 2,
|
||||||
|
Buckets: 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, buckets, 1)
|
||||||
|
assert.EqualValues(t, 2, buckets[0].ListenCount, "Track 2 should have 2 listens")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -44,3 +44,9 @@ type ExportItem struct {
|
||||||
ReleaseAliases []models.Alias
|
ReleaseAliases []models.Alias
|
||||||
Artists []models.ArtistWithFullAliases
|
Artists []models.ArtistWithFullAliases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InterestBucket struct {
|
||||||
|
BucketStart time.Time `json:"bucket_start"`
|
||||||
|
BucketEnd time.Time `json:"bucket_end"`
|
||||||
|
ListenCount int64 `json:"listen_count"`
|
||||||
|
}
|
||||||
|
|
|
||||||
270
internal/repository/interest.sql.go
Normal file
270
internal/repository/interest.sql.go
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: interest.sql
|
||||||
|
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getGroupedListensFromArtist = `-- name: GetGroupedListensFromArtist :many
|
||||||
|
WITH artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.listened_at
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
JOIN artist_tracks at ON at.track_id = t.id
|
||||||
|
WHERE at.artist_id = $1
|
||||||
|
),
|
||||||
|
bounds AS (
|
||||||
|
SELECT
|
||||||
|
MIN(listened_at) AS start_time,
|
||||||
|
MAX(listened_at) AS end_time
|
||||||
|
FROM artist_listens
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
$2 - 1,
|
||||||
|
FLOOR(
|
||||||
|
(
|
||||||
|
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
|
||||||
|
/
|
||||||
|
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
|
||||||
|
) * $2
|
||||||
|
)::int
|
||||||
|
) AS bucket_idx,
|
||||||
|
b.start_time,
|
||||||
|
b.end_time
|
||||||
|
FROM artist_listens al
|
||||||
|
CROSS JOIN bounds b
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
bucket_idx * (end_time - start_time)
|
||||||
|
/ $2
|
||||||
|
) AS bucket_start,
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
(bucket_idx + 1) * (end_time - start_time)
|
||||||
|
/ $2
|
||||||
|
) AS bucket_end,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_idx, start_time, end_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start::timestamptz,
|
||||||
|
bucket_end::timestamptz,
|
||||||
|
listen_count
|
||||||
|
FROM aggregated
|
||||||
|
ORDER BY bucket_start
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetGroupedListensFromArtistParams struct {
|
||||||
|
ArtistID int32
|
||||||
|
BucketCount interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetGroupedListensFromArtistRow struct {
|
||||||
|
BucketStart time.Time
|
||||||
|
BucketEnd time.Time
|
||||||
|
ListenCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetGroupedListensFromArtist(ctx context.Context, arg GetGroupedListensFromArtistParams) ([]GetGroupedListensFromArtistRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getGroupedListensFromArtist, arg.ArtistID, arg.BucketCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetGroupedListensFromArtistRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetGroupedListensFromArtistRow
|
||||||
|
if err := rows.Scan(&i.BucketStart, &i.BucketEnd, &i.ListenCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGroupedListensFromRelease = `-- name: GetGroupedListensFromRelease :many
|
||||||
|
WITH artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.listened_at
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
WHERE t.release_id = $1
|
||||||
|
),
|
||||||
|
bounds AS (
|
||||||
|
SELECT
|
||||||
|
MIN(listened_at) AS start_time,
|
||||||
|
MAX(listened_at) AS end_time
|
||||||
|
FROM artist_listens
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
$2 - 1,
|
||||||
|
FLOOR(
|
||||||
|
(
|
||||||
|
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
|
||||||
|
/
|
||||||
|
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
|
||||||
|
) * $2
|
||||||
|
)::int
|
||||||
|
) AS bucket_idx,
|
||||||
|
b.start_time,
|
||||||
|
b.end_time
|
||||||
|
FROM artist_listens al
|
||||||
|
CROSS JOIN bounds b
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
bucket_idx * (end_time - start_time)
|
||||||
|
/ $2
|
||||||
|
) AS bucket_start,
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
(bucket_idx + 1) * (end_time - start_time)
|
||||||
|
/ $2
|
||||||
|
) AS bucket_end,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_idx, start_time, end_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start::timestamptz,
|
||||||
|
bucket_end::timestamptz,
|
||||||
|
listen_count
|
||||||
|
FROM aggregated
|
||||||
|
ORDER BY bucket_start
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetGroupedListensFromReleaseParams struct {
|
||||||
|
ReleaseID int32
|
||||||
|
BucketCount interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetGroupedListensFromReleaseRow struct {
|
||||||
|
BucketStart time.Time
|
||||||
|
BucketEnd time.Time
|
||||||
|
ListenCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetGroupedListensFromRelease(ctx context.Context, arg GetGroupedListensFromReleaseParams) ([]GetGroupedListensFromReleaseRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getGroupedListensFromRelease, arg.ReleaseID, arg.BucketCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetGroupedListensFromReleaseRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetGroupedListensFromReleaseRow
|
||||||
|
if err := rows.Scan(&i.BucketStart, &i.BucketEnd, &i.ListenCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGroupedListensFromTrack = `-- name: GetGroupedListensFromTrack :many
|
||||||
|
WITH artist_listens AS (
|
||||||
|
SELECT
|
||||||
|
l.listened_at
|
||||||
|
FROM listens l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
WHERE t.id = $1
|
||||||
|
),
|
||||||
|
bounds AS (
|
||||||
|
SELECT
|
||||||
|
MIN(listened_at) AS start_time,
|
||||||
|
MAX(listened_at) AS end_time
|
||||||
|
FROM artist_listens
|
||||||
|
),
|
||||||
|
bucketed AS (
|
||||||
|
SELECT
|
||||||
|
LEAST(
|
||||||
|
$2 - 1,
|
||||||
|
FLOOR(
|
||||||
|
(
|
||||||
|
EXTRACT(EPOCH FROM (al.listened_at - b.start_time))
|
||||||
|
/
|
||||||
|
NULLIF(EXTRACT(EPOCH FROM (b.end_time - b.start_time)), 0)
|
||||||
|
) * $2
|
||||||
|
)::int
|
||||||
|
) AS bucket_idx,
|
||||||
|
b.start_time,
|
||||||
|
b.end_time
|
||||||
|
FROM artist_listens al
|
||||||
|
CROSS JOIN bounds b
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
bucket_idx * (end_time - start_time)
|
||||||
|
/ $2
|
||||||
|
) AS bucket_start,
|
||||||
|
start_time
|
||||||
|
+ (
|
||||||
|
(bucket_idx + 1) * (end_time - start_time)
|
||||||
|
/ $2
|
||||||
|
) AS bucket_end,
|
||||||
|
COUNT(*) AS listen_count
|
||||||
|
FROM bucketed
|
||||||
|
GROUP BY bucket_idx, start_time, end_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
bucket_start::timestamptz,
|
||||||
|
bucket_end::timestamptz,
|
||||||
|
listen_count
|
||||||
|
FROM aggregated
|
||||||
|
ORDER BY bucket_start
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetGroupedListensFromTrackParams struct {
|
||||||
|
ID int32
|
||||||
|
BucketCount interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetGroupedListensFromTrackRow struct {
|
||||||
|
BucketStart time.Time
|
||||||
|
BucketEnd time.Time
|
||||||
|
ListenCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetGroupedListensFromTrack(ctx context.Context, arg GetGroupedListensFromTrackParams) ([]GetGroupedListensFromTrackRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getGroupedListensFromTrack, arg.ID, arg.BucketCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetGroupedListensFromTrackRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetGroupedListensFromTrackRow
|
||||||
|
if err := rows.Scan(&i.BucketStart, &i.BucketEnd, &i.ListenCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue