From 6fc10c40caa3d79c0e0e8b931e0fd194942fc47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 31 May 2020 23:26:06 +0200 Subject: [PATCH] feat: add top-artists report --- frontend/src/App.tsx | 2 + frontend/src/api/api.ts | 38 ++++++++++- frontend/src/api/entities/album.ts | 7 ++ frontend/src/api/entities/artist.ts | 7 ++ frontend/src/api/entities/listen.ts | 29 +-------- frontend/src/api/entities/spotify-info.ts | 6 ++ frontend/src/api/entities/time-preset.enum.ts | 9 +++ frontend/src/api/entities/top-artists-item.ts | 6 ++ .../src/api/entities/top-artists-options.ts | 7 ++ frontend/src/api/entities/track.ts | 11 ++++ frontend/src/components/NavBar.tsx | 3 + frontend/src/components/ReportOptions.tsx | 65 +++++++++++++++++++ frontend/src/components/ReportTopArtists.tsx | 62 ++++++++++++++++++ frontend/src/components/inputs/DateSelect.tsx | 31 +++++++++ src/reports/dto/get-top-artists-report.dto.ts | 18 +++++ src/reports/dto/top-artists-report.dto.ts | 8 +++ src/reports/reports.controller.ts | 11 ++++ src/reports/reports.service.ts | 55 +++++++++++++++- 18 files changed, 345 insertions(+), 30 deletions(-) create mode 100644 frontend/src/api/entities/album.ts create mode 100644 frontend/src/api/entities/artist.ts create mode 100644 frontend/src/api/entities/spotify-info.ts create mode 100644 frontend/src/api/entities/time-preset.enum.ts create mode 100644 frontend/src/api/entities/top-artists-item.ts create mode 100644 frontend/src/api/entities/top-artists-options.ts create mode 100644 frontend/src/api/entities/track.ts create mode 100644 frontend/src/components/ReportOptions.tsx create mode 100644 frontend/src/components/ReportTopArtists.tsx create mode 100644 frontend/src/components/inputs/DateSelect.tsx create mode 100644 src/reports/dto/get-top-artists-report.dto.ts create mode 100644 src/reports/dto/top-artists-report.dto.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69907bd..83009fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { RecentListens } from "./components/RecentListens"; import { ReportListens } from "./components/ReportListens"; import { useAuth } from "./hooks/use-auth"; import "./tailwind/generated.css"; +import { ReportTopArtists } from "./components/ReportTopArtists"; export function App() { const { isLoaded } = useAuth(); @@ -26,6 +27,7 @@ export function App() { + ); diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 1168647..1952023 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,11 +1,13 @@ import { formatISO, parseISO } from "date-fns"; +import { qs } from "../util/queryString"; import { Listen } from "./entities/listen"; import { ListenReportItem } from "./entities/listen-report-item"; import { ListenReportOptions } from "./entities/listen-report-options"; import { Pagination } from "./entities/pagination"; import { PaginationOptions } from "./entities/pagination-options"; +import { TopArtistsItem } from "./entities/top-artists-item"; +import { TopArtistsOptions } from "./entities/top-artists-options"; import { User } from "./entities/user"; -import { qs } from "../util/queryString"; export class UnauthenticatedError extends Error {} @@ -99,10 +101,42 @@ export const getListensReport = async ( throw new UnauthenticatedError(`No token or token expired`); } default: { - throw new Error(`Unable to getRecentListens: ${res.status}`); + throw new Error(`Unable to getListensReport: ${res.status}`); } } const rawItems: { count: number; date: string }[] = (await res.json()).items; return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) })); }; + +export const getTopArtists = async ( + options: TopArtistsOptions +): Promise => { + const { timePreset, customTimeStart, customTimeEnd } = options; + + const res = await fetch( + `/api/v1/reports/top-artists?${qs({ + timePreset, + customTimeStart: formatISO(customTimeStart), + customTimeEnd: formatISO(customTimeEnd), + })}`, + { + headers: getDefaultHeaders(), + } + ); + + switch (res.status) { + case 200: { + break; + } + case 401: { + throw new UnauthenticatedError(`No token or token expired`); + } + default: { + throw new Error(`Unable to getTopArtists: ${res.status}`); + } + } + + const items: TopArtistsItem[] = (await res.json()).items; + return items; +}; diff --git a/frontend/src/api/entities/album.ts b/frontend/src/api/entities/album.ts new file mode 100644 index 0000000..2fb1059 --- /dev/null +++ b/frontend/src/api/entities/album.ts @@ -0,0 +1,7 @@ +import { SpotifyInfo } from "./spotify-info"; + +export interface Album { + id: string; + name: string; + spotify?: SpotifyInfo; +} diff --git a/frontend/src/api/entities/artist.ts b/frontend/src/api/entities/artist.ts new file mode 100644 index 0000000..25aba94 --- /dev/null +++ b/frontend/src/api/entities/artist.ts @@ -0,0 +1,7 @@ +import { SpotifyInfo } from "./spotify-info"; + +export interface Artist { + id: string; + name: string; + spotify?: SpotifyInfo; +} diff --git a/frontend/src/api/entities/listen.ts b/frontend/src/api/entities/listen.ts index 4c402b4..94adde0 100644 --- a/frontend/src/api/entities/listen.ts +++ b/frontend/src/api/entities/listen.ts @@ -1,32 +1,7 @@ +import { Track } from "./track"; + export interface Listen { id: string; playedAt: string; track: Track; } - -interface Track { - id: string; - name: string; - album: Album; - artists: Artist[]; - spotify?: SpotifyInfo; -} - -interface Album { - id: string; - name: string; - spotify?: SpotifyInfo; -} - -interface Artist { - id: string; - name: string; - spotify?: SpotifyInfo; -} - -interface SpotifyInfo { - id: string; - uri: string; - type: string; - href: string; -} diff --git a/frontend/src/api/entities/spotify-info.ts b/frontend/src/api/entities/spotify-info.ts new file mode 100644 index 0000000..c02e03f --- /dev/null +++ b/frontend/src/api/entities/spotify-info.ts @@ -0,0 +1,6 @@ +export interface SpotifyInfo { + id: string; + uri: string; + type: string; + href: string; +} diff --git a/frontend/src/api/entities/time-preset.enum.ts b/frontend/src/api/entities/time-preset.enum.ts new file mode 100644 index 0000000..d358523 --- /dev/null +++ b/frontend/src/api/entities/time-preset.enum.ts @@ -0,0 +1,9 @@ +export enum TimePreset { + LAST_7_DAYS = "last_7_days", + LAST_30_DAYS = "last_30_days", + LAST_90_DAYS = "last_90_days", + LAST_180_DAYS = "last_180_days", + LAST_365_DAYS = "last_365_days", + ALL_TIME = "all_time", + CUSTOM = "custom", +} diff --git a/frontend/src/api/entities/top-artists-item.ts b/frontend/src/api/entities/top-artists-item.ts new file mode 100644 index 0000000..e965d34 --- /dev/null +++ b/frontend/src/api/entities/top-artists-item.ts @@ -0,0 +1,6 @@ +import { Artist } from "./artist"; + +export interface TopArtistsItem { + artist: Artist; + count: number; +} diff --git a/frontend/src/api/entities/top-artists-options.ts b/frontend/src/api/entities/top-artists-options.ts new file mode 100644 index 0000000..c5114d4 --- /dev/null +++ b/frontend/src/api/entities/top-artists-options.ts @@ -0,0 +1,7 @@ +import { TimePreset } from "./time-preset.enum"; + +export interface TopArtistsOptions { + timePreset: TimePreset; + customTimeStart: Date; + customTimeEnd: Date; +} diff --git a/frontend/src/api/entities/track.ts b/frontend/src/api/entities/track.ts new file mode 100644 index 0000000..b66da8a --- /dev/null +++ b/frontend/src/api/entities/track.ts @@ -0,0 +1,11 @@ +import { Album } from "./album"; +import { Artist } from "./artist"; +import { SpotifyInfo } from "./spotify-info"; + +export interface Track { + id: string; + name: string; + album: Album; + artists: Artist[]; + spotify?: SpotifyInfo; +} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 19c9942..4d85308 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -25,6 +25,9 @@ export const NavBar: React.FC = () => { Listens Report + + Top Artists + )} diff --git a/frontend/src/components/ReportOptions.tsx b/frontend/src/components/ReportOptions.tsx new file mode 100644 index 0000000..14620ff --- /dev/null +++ b/frontend/src/components/ReportOptions.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { TimePreset } from "../api/entities/time-preset.enum"; +import { TopArtistsOptions } from "../api/entities/top-artists-options"; +import { DateSelect } from "./inputs/DateSelect"; + +interface ReportOptionsProps { + reportOptions: TopArtistsOptions; + setReportOptions: (options: TopArtistsOptions) => void; +} + +const timePresetOptions = [ + { value: TimePreset.LAST_7_DAYS, description: "Last 7 days" }, + { value: TimePreset.LAST_30_DAYS, description: "Last 30 days" }, + { value: TimePreset.LAST_90_DAYS, description: "Last 90 days" }, + { value: TimePreset.LAST_180_DAYS, description: "Last 180 days" }, + { value: TimePreset.LAST_365_DAYS, description: "Last 365 days" }, + { value: TimePreset.ALL_TIME, description: "All time" }, + { value: TimePreset.CUSTOM, description: "Custom" }, +]; + +export const ReportOptions: React.FC = ({ + reportOptions, + setReportOptions, +}) => { + return ( +
+
+ + +
+ {reportOptions.timePreset === TimePreset.CUSTOM && ( +
+ + setReportOptions({ ...reportOptions, customTimeStart: newDate }) + } + /> + + setReportOptions({ ...reportOptions, customTimeEnd: newDate }) + } + /> +
+ )} +
+ ); +}; diff --git a/frontend/src/components/ReportTopArtists.tsx b/frontend/src/components/ReportTopArtists.tsx new file mode 100644 index 0000000..6b08676 --- /dev/null +++ b/frontend/src/components/ReportTopArtists.tsx @@ -0,0 +1,62 @@ +import React, { useMemo, useState } from "react"; +import { Redirect } from "react-router-dom"; +import { getTopArtists } from "../api/api"; +import { TimePreset } from "../api/entities/time-preset.enum"; +import { TopArtistsOptions } from "../api/entities/top-artists-options"; +import { useAsync } from "../hooks/use-async"; +import { useAuth } from "../hooks/use-auth"; +import { ReportOptions } from "./ReportOptions"; + +export const ReportTopArtists: React.FC = () => { + const { user } = useAuth(); + + const [reportOptions, setReportOptions] = useState({ + timePreset: TimePreset.LAST_90_DAYS, + customTimeStart: new Date(0), + customTimeEnd: new Date(), + }); + + const fetchData = useMemo(() => () => getTopArtists(reportOptions), [ + reportOptions, + ]); + + const { value: report, pending: isLoading } = useAsync(fetchData, []); + + const reportHasItems = !isLoading && report.length !== 0; + + if (!user) { + return ; + } + + return ( +
+
+
+

Top Artists

+
+
+ + {isLoading && ( +
+
+
+ )} + {!reportHasItems && ( +
+

Report is emtpy! :(

+
+ )} + {reportHasItems && + report.map(({ artist, count }) => ( +
+ {count} - {artist.name} +
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/inputs/DateSelect.tsx b/frontend/src/components/inputs/DateSelect.tsx new file mode 100644 index 0000000..82af065 --- /dev/null +++ b/frontend/src/components/inputs/DateSelect.tsx @@ -0,0 +1,31 @@ +import { format, parse } from "date-fns"; +import React from "react"; + +const parseDateFromDateInput = (input: string) => + parse(input, "yyyy-MM-dd", new Date()); + +const formatDateForDateInput = (date: Date) => format(date, "yyyy-MM-dd"); + +interface DateSelectProps { + label: string; + value: Date; + onChange: (date: Date) => void; +} + +export const DateSelect: React.FC = ({ + label, + value, + onChange, +}) => { + return ( +
+ + onChange(parseDateFromDateInput(e.target.value))} + /> +
+ ); +}; diff --git a/src/reports/dto/get-top-artists-report.dto.ts b/src/reports/dto/get-top-artists-report.dto.ts new file mode 100644 index 0000000..449eab0 --- /dev/null +++ b/src/reports/dto/get-top-artists-report.dto.ts @@ -0,0 +1,18 @@ +import { IsEnum, IsISO8601, ValidateIf } from "class-validator"; +import { User } from "../../users/user.entity"; +import { TimePreset } from "../timePreset.enum"; + +export class GetTopArtistsReportDto { + user: User; + + @IsEnum(TimePreset) + timePreset: TimePreset; + + @ValidateIf((o) => o.timePreset === TimePreset.CUSTOM) + @IsISO8601() + customTimeStart: string; + + @ValidateIf((o) => o.timePreset === TimePreset.CUSTOM) + @IsISO8601() + customTimeEnd: string; +} diff --git a/src/reports/dto/top-artists-report.dto.ts b/src/reports/dto/top-artists-report.dto.ts new file mode 100644 index 0000000..088b305 --- /dev/null +++ b/src/reports/dto/top-artists-report.dto.ts @@ -0,0 +1,8 @@ +import { Artist } from "../../music-library/artist.entity"; + +export class TopArtistsReportDto { + items: { + artist: Artist; + count: number; + }[]; +} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 14d5433..a51ca20 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -3,7 +3,9 @@ import { Auth } from "src/auth/decorators/auth.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator"; import { User } from "../users/user.entity"; import { GetListenReportDto } from "./dto/get-listen-report.dto"; +import { GetTopArtistsReportDto } from "./dto/get-top-artists-report.dto"; import { ListenReportDto } from "./dto/listen-report.dto"; +import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; import { ReportsService } from "./reports.service"; @Controller("api/v1/reports") @@ -18,4 +20,13 @@ export class ReportsController { ): Promise { return this.reportsService.getListens({ ...options, user }); } + + @Get("top-artists") + @Auth() + async getTopArtists( + @Query() options: Omit, + @ReqUser() user: User + ): Promise { + return this.reportsService.getTopArtists({ ...options, user }); + } } diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index 58eb9b2..e816f4a 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -17,7 +17,10 @@ import { } from "date-fns"; import { ListensService } from "../listens/listens.service"; import { GetListenReportDto } from "./dto/get-listen-report.dto"; +import { GetTopArtistsReportDto } from "./dto/get-top-artists-report.dto"; import { ListenReportDto } from "./dto/listen-report.dto"; +import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; +import { Interval } from "./interval"; import { Timeframe } from "./timeframe.enum"; import { TimePreset } from "./timePreset.enum"; @@ -55,6 +58,8 @@ const timePresetToDays: { [x in TimePreset]: number } = { [TimePreset.CUSTOM]: 0, // Not used for this }; +const PAGINATION_LIMIT_UNLIMITED = 10000000; + @Injectable() export class ReportsService { constructor(private readonly listensService: ListensService) {} @@ -73,7 +78,7 @@ export class ReportsService { user, filter: { time: interval }, page: 1, - limit: 10000000, + limit: PAGINATION_LIMIT_UNLIMITED, }); const reportInterval: Interval = { @@ -91,6 +96,54 @@ export class ReportsService { return { items: reportItems, timeStart, timeEnd, timeFrame }; } + + async getTopArtists( + options: GetTopArtistsReportDto + ): Promise { + const { user, timePreset, customTimeStart, customTimeEnd } = options; + + const interval = this.getIntervalFromPreset({ + timePreset, + customTimeStart, + customTimeEnd, + }); + + const { items: listens } = await this.listensService.getListens({ + user, + filter: { time: interval }, + page: 1, + limit: PAGINATION_LIMIT_UNLIMITED, + }); + + // Declare types for metrics calculation + type Item = TopArtistsReportDto["items"][0]; + type Accumulator = { + [x: string]: Item; + }; + + const items: TopArtistsReportDto["items"] = Object.values( + listens + .flatMap((listen) => listen.track.artists) + .reduce((counters, artist) => { + if (!counters[artist.id]) { + counters[artist.id] = { + artist, + count: 0, + }; + } + + counters[artist.id].count += 1; + + return counters; + }, {}) + ) + .sort((a, b) => a.count - b.count) + .reverse() // sort descending + .slice(0, 20); // TODO: Make configurable + + return { items }; + } + private getIntervalFromPreset(options: { timePreset: TimePreset; customTimeStart?: string;