From 51fd78f6d916eb26581745a0ce4109d9136b8084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 22 May 2021 14:57:28 +0200 Subject: [PATCH] feat: add top tracks report --- frontend/src/App.tsx | 2 + frontend/src/api/api.ts | 39 +++++++++ frontend/src/api/entities/top-tracks-item.ts | 6 ++ .../src/api/entities/top-tracks-options.ts | 5 ++ frontend/src/components/NavBar.tsx | 3 + frontend/src/components/ReportTopTracks.tsx | 80 +++++++++++++++++++ frontend/src/hooks/use-api.tsx | 18 +++++ src/reports/dto/get-top-tracks-report.dto.ts | 10 +++ src/reports/dto/top-tracks-report.dto.ts | 8 ++ src/reports/reports.controller.ts | 10 +++ src/reports/reports.service.ts | 49 ++++++++++++ 11 files changed, 230 insertions(+) create mode 100644 frontend/src/api/entities/top-tracks-item.ts create mode 100644 frontend/src/api/entities/top-tracks-options.ts create mode 100644 frontend/src/components/ReportTopTracks.tsx create mode 100644 src/reports/dto/get-top-tracks-report.dto.ts create mode 100644 src/reports/dto/top-tracks-report.dto.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eabcfaf..8c8e4c0 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 { ReportTopAlbums } from "./components/ReportTopAlbums"; import { ReportTopArtists } from "./components/ReportTopArtists"; +import { ReportTopTracks } from "./components/ReportTopTracks"; import { useAuth } from "./hooks/use-auth"; import "./tailwind/generated.css"; @@ -30,6 +31,7 @@ export function App() { + ); diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 2b95506..6b01a34 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -9,6 +9,8 @@ import { TopAlbumsItem } from "./entities/top-albums-item"; import { TopAlbumsOptions } from "./entities/top-albums-options"; import { TopArtistsItem } from "./entities/top-artists-item"; import { TopArtistsOptions } from "./entities/top-artists-options"; +import { TopTracksItem } from "./entities/top-tracks-item"; +import { TopTracksOptions } from "./entities/top-tracks-options"; export class UnauthenticatedError extends Error {} @@ -149,3 +151,40 @@ export const getTopAlbums = async ( } = res; return items; }; + +export const getTopTracks = async ( + options: TopTracksOptions, + client: AxiosInstance +): Promise => { + const { + time: { timePreset, customTimeStart, customTimeEnd }, + } = options; + + const res = await client.get<{ items: TopTracksItem[] }>( + `/api/v1/reports/top-tracks`, + { + params: { + timePreset, + customTimeStart: formatISO(customTimeStart), + customTimeEnd: formatISO(customTimeEnd), + }, + } + ); + + switch (res.status) { + case 200: { + break; + } + case 401: { + throw new UnauthenticatedError(`No token or token expired`); + } + default: { + throw new Error(`Unable to getTopTracks: ${res.status}`); + } + } + + const { + data: { items }, + } = res; + return items; +}; diff --git a/frontend/src/api/entities/top-tracks-item.ts b/frontend/src/api/entities/top-tracks-item.ts new file mode 100644 index 0000000..213cced --- /dev/null +++ b/frontend/src/api/entities/top-tracks-item.ts @@ -0,0 +1,6 @@ +import { Track } from "./track"; + +export interface TopTracksItem { + track: Track; + count: number; +} diff --git a/frontend/src/api/entities/top-tracks-options.ts b/frontend/src/api/entities/top-tracks-options.ts new file mode 100644 index 0000000..6604d12 --- /dev/null +++ b/frontend/src/api/entities/top-tracks-options.ts @@ -0,0 +1,5 @@ +import { TimeOptions } from "./time-options"; + +export interface TopTracksOptions { + time: TimeOptions; +} diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 253c581..8f78495 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -31,6 +31,9 @@ export const NavBar: React.FC = () => { Top Albums + + Top Tracks + )} diff --git a/frontend/src/components/ReportTopTracks.tsx b/frontend/src/components/ReportTopTracks.tsx new file mode 100644 index 0000000..e3ef730 --- /dev/null +++ b/frontend/src/components/ReportTopTracks.tsx @@ -0,0 +1,80 @@ +import React, { useMemo, useState } from "react"; +import { Redirect } from "react-router-dom"; +import { TimeOptions } from "../api/entities/time-options"; +import { TimePreset } from "../api/entities/time-preset.enum"; +import { Track } from "../api/entities/track"; +import { useTopTracks } from "../hooks/use-api"; +import { useAuth } from "../hooks/use-auth"; +import { ReportTimeOptions } from "./ReportTimeOptions"; +import { TopListItem } from "./TopListItem"; + +export const ReportTopTracks: React.FC = () => { + const { user } = useAuth(); + + const [timeOptions, setTimeOptions] = useState({ + timePreset: TimePreset.LAST_90_DAYS, + customTimeStart: new Date(0), + customTimeEnd: new Date(), + }); + + const options = useMemo( + () => ({ + time: timeOptions, + }), + [timeOptions] + ); + + const { topTracks, isLoading } = useTopTracks(options); + + const reportHasItems = !isLoading && topTracks.length !== 0; + + if (!user) { + return ; + } + + return ( +
+
+
+

Top Tracks

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

Report is emtpy! :(

+
+ )} + {reportHasItems && + topTracks.map(({ track, count }) => ( + + ))} +
+
+
+ ); +}; + +const ReportItem: React.FC<{ + track: Track; + count: number; +}> = ({ track, count }) => { + const artists = track.artists?.map((artist) => artist.name).join(", ") || ""; + + return ( + + ); +}; diff --git a/frontend/src/hooks/use-api.tsx b/frontend/src/hooks/use-api.tsx index dfeb7b0..2272cb1 100644 --- a/frontend/src/hooks/use-api.tsx +++ b/frontend/src/hooks/use-api.tsx @@ -4,11 +4,13 @@ import { getRecentListens, getTopAlbums, getTopArtists, + getTopTracks, } from "../api/api"; import { ListenReportOptions } from "../api/entities/listen-report-options"; import { PaginationOptions } from "../api/entities/pagination-options"; import { TopAlbumsOptions } from "../api/entities/top-albums-options"; import { TopArtistsOptions } from "../api/entities/top-artists-options"; +import { TopTracksOptions } from "../api/entities/top-tracks-options"; import { useApiClient } from "./use-api-client"; import { useAsync } from "./use-async"; @@ -81,3 +83,19 @@ export const useTopAlbums = (options: TopAlbumsOptions) => { return { topAlbums, isLoading, error }; }; + +export const useTopTracks = (options: TopTracksOptions) => { + const { client } = useApiClient(); + + const fetchData = useMemo(() => () => getTopTracks(options, client), [ + options, + client, + ]); + + const { value: topTracks, pending: isLoading, error } = useAsync( + fetchData, + INITIAL_EMPTY_ARRAY + ); + + return { topTracks, isLoading, error }; +}; diff --git a/src/reports/dto/get-top-tracks-report.dto.ts b/src/reports/dto/get-top-tracks-report.dto.ts new file mode 100644 index 0000000..14c804c --- /dev/null +++ b/src/reports/dto/get-top-tracks-report.dto.ts @@ -0,0 +1,10 @@ +import { ValidateNested } from "class-validator"; +import { User } from "../../users/user.entity"; +import { ReportTimeDto } from "./report-time.dto"; + +export class GetTopTracksReportDto { + user: User; + + @ValidateNested() + time: ReportTimeDto; +} diff --git a/src/reports/dto/top-tracks-report.dto.ts b/src/reports/dto/top-tracks-report.dto.ts new file mode 100644 index 0000000..0323b45 --- /dev/null +++ b/src/reports/dto/top-tracks-report.dto.ts @@ -0,0 +1,8 @@ +import { Track } from "../../music-library/track.entity"; + +export class TopTracksReportDto { + items: { + track: Track; + count: number; + }[]; +} diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 368b763..efa7ca5 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -6,6 +6,7 @@ import { ListenReportDto } from "./dto/listen-report.dto"; import { ReportTimeDto } from "./dto/report-time.dto"; import { TopAlbumsReportDto } from "./dto/top-albums-report.dto"; import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; +import { TopTracksReportDto } from "./dto/top-tracks-report.dto"; import { ReportsService } from "./reports.service"; import { Timeframe } from "./timeframe.enum"; @@ -40,4 +41,13 @@ export class ReportsController { ): Promise { return this.reportsService.getTopAlbums({ user, time }); } + + @Get("top-tracks") + @AuthAccessToken() + async getTopTracks( + @Query() time: ReportTimeDto, + @ReqUser() user: User + ): Promise { + return this.reportsService.getTopTracks({ user, time }); + } } diff --git a/src/reports/reports.service.ts b/src/reports/reports.service.ts index eebea76..a8cfdd9 100644 --- a/src/reports/reports.service.ts +++ b/src/reports/reports.service.ts @@ -18,9 +18,11 @@ import { ListensService } from "../listens/listens.service"; import { GetListenReportDto } from "./dto/get-listen-report.dto"; import { GetTopAlbumsReportDto } from "./dto/get-top-albums-report.dto"; import { GetTopArtistsReportDto } from "./dto/get-top-artists-report.dto"; +import { GetTopTracksReportDto } from "./dto/get-top-tracks-report.dto"; import { ListenReportDto } from "./dto/listen-report.dto"; import { TopAlbumsReportDto } from "./dto/top-albums-report.dto"; import { TopArtistsReportDto } from "./dto/top-artists-report.dto"; +import { TopTracksReportDto } from "./dto/top-tracks-report.dto"; import { Interval } from "./interval"; import { Timeframe } from "./timeframe.enum"; import { TimePreset } from "./timePreset.enum"; @@ -175,6 +177,53 @@ export class ReportsService { }; } + async getTopTracks( + options: GetTopTracksReportDto + ): Promise { + const { user, time: timePreset } = options; + + const interval = this.getIntervalFromPreset(timePreset); + + const getListensQB = () => + this.listensService + .getScopedQueryBuilder() + .byUser(user) + .duringInterval(interval); + + const [rawTracksWithCount, rawTrackDetails] = await Promise.all([ + getListensQB() + .leftJoin("listen.track", "track") + .groupBy("track.id") + .select("track.*") + .addSelect("count(*) as listens") + .orderBy("listens", "DESC") + .getRawMany(), + + // Because of the GROUP BY required to calculate the count we can + // not properly join the artist relations in one query + getListensQB() + .leftJoinAndSelect("listen.track", "track") + .leftJoinAndSelect("track.artists", "artists") + .distinctOn(["track.id"]) + .getMany(), + ]); + + const trackDetails = rawTrackDetails + .map((listen) => listen.track) + .filter((track) => track); // Make sure entities are set + + const items: TopTracksReportDto["items"] = rawTracksWithCount.map( + (data) => ({ + count: Number.parseInt(data.listens, 10), + track: trackDetails.find((track) => track.id === data.id), + }) + ); + + return { + items, + }; + } + private getIntervalFromPreset(options: { timePreset: TimePreset; customTimeStart?: string;