diff --git a/server/utils/personCreditHelpers.test.ts b/server/utils/personCreditHelpers.test.ts new file mode 100644 index 0000000000..c791742f82 --- /dev/null +++ b/server/utils/personCreditHelpers.test.ts @@ -0,0 +1,159 @@ +import type { PersonCreditCast } from '@server/models/Person'; +import { + DEFAULT_PERSON_CREDIT_SORT, + sortPersonCredits, +} from '@server/utils/personCreditHelpers'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const makeCredit = ( + overrides: Partial & Pick +): PersonCreditCast => ({ + originalLanguage: 'en', + episodeCount: 0, + overview: '', + originCountry: [], + originalName: '', + voteCount: 0, + name: '', + popularity: 0, + creditId: `credit-${overrides.id}`, + firstAirDate: '', + voteAverage: 0, + originalTitle: '', + title: '', + adult: false, + releaseDate: '', + character: '', + ...overrides, +}); + +describe('personCreditHelpers', () => { + it('defaults to vote count descending', () => { + assert.equal(DEFAULT_PERSON_CREDIT_SORT, 'voteCount.desc'); + }); + + it('sorts by vote count descending', () => { + const credits = [ + makeCredit({ id: 1, title: 'Low', voteCount: 10 }), + makeCredit({ id: 2, title: 'High', voteCount: 100 }), + ]; + + const sorted = sortPersonCredits( + credits, + 'voteCount.desc', + (credit) => credit.id, + (objs) => objs[0] + ); + + assert.deepEqual( + sorted.map((credit) => credit.id), + [2, 1] + ); + }); + + it('sorts movies by release date and series by first air date', () => { + const credits = [ + makeCredit({ + id: 1, + mediaType: 'movie', + title: 'Older Movie', + releaseDate: '2000-01-01', + }), + makeCredit({ + id: 2, + mediaType: 'tv', + name: 'Newer Series', + firstAirDate: '2020-01-01', + }), + makeCredit({ + id: 3, + mediaType: 'movie', + title: 'Newer Movie', + releaseDate: '2010-01-01', + }), + ]; + + const sorted = sortPersonCredits( + credits, + 'releaseDate.desc', + (credit) => credit.id, + (objs) => objs[0] + ); + + assert.deepEqual( + sorted.map((credit) => credit.id), + [2, 3, 1] + ); + }); + + it('places credits without release dates last when sorting by release date', () => { + const credits = [ + makeCredit({ + id: 1, + mediaType: 'movie', + title: 'Undated', + releaseDate: '', + }), + makeCredit({ + id: 2, + mediaType: 'movie', + title: 'Dated', + releaseDate: '2015-01-01', + }), + ]; + + const sorted = sortPersonCredits( + credits, + 'releaseDate.desc', + (credit) => credit.id, + (objs) => objs[0] + ); + + assert.deepEqual( + sorted.map((credit) => credit.id), + [2, 1] + ); + }); + + it('sorts by title ascending using localized title fields', () => { + const credits = [ + makeCredit({ id: 1, mediaType: 'movie', title: 'Zulu' }), + makeCredit({ id: 2, mediaType: 'tv', name: 'Alpha' }), + ]; + + const sorted = sortPersonCredits( + credits, + 'title.asc', + (credit) => credit.id, + (objs) => objs[0] + ); + + assert.deepEqual( + sorted.map((credit) => credit.id), + [2, 1] + ); + }); + + it('groups duplicate credits before sorting', () => { + const credits = [ + makeCredit({ id: 1, title: 'Popular', voteCount: 50, character: 'A' }), + makeCredit({ id: 1, title: 'Popular', voteCount: 50, character: 'B' }), + makeCredit({ id: 2, title: 'Less Popular', voteCount: 100 }), + ]; + + const sorted = sortPersonCredits( + credits, + 'voteCount.desc', + (credit) => credit.id, + (objs) => ({ + ...objs[0], + character: objs.map((obj) => obj.character).join(', '), + }) + ); + + assert.equal(sorted.length, 2); + assert.equal(sorted[0].id, 2); + assert.equal(sorted[1].character, 'A, B'); + }); +}); diff --git a/server/utils/personCreditHelpers.ts b/server/utils/personCreditHelpers.ts new file mode 100644 index 0000000000..bf2e7fbe31 --- /dev/null +++ b/server/utils/personCreditHelpers.ts @@ -0,0 +1,82 @@ +import type { PersonCredit } from '@server/models/Person'; +import { groupBy } from 'lodash'; + +export type PersonCreditSort = + | 'releaseDate.desc' + | 'releaseDate.asc' + | 'title.asc' + | 'title.desc' + | 'voteCount.desc' + | 'voteCount.asc'; + +export const DEFAULT_PERSON_CREDIT_SORT: PersonCreditSort = 'voteCount.desc'; + +const getReleaseDate = (credit: PersonCredit): string => + credit.mediaType === 'movie' ? credit.releaseDate : credit.firstAirDate; + +const getTitle = (credit: PersonCredit): string => + credit.mediaType === 'movie' + ? credit.originalTitle || credit.title + : credit.originalName || credit.name; + +const compareStrings = ( + a: string, + b: string, + direction: 'asc' | 'desc' +): number => { + const result = (a || '').localeCompare(b || '', undefined, { + sensitivity: 'base', + }); + + return direction === 'asc' ? result : -result; +}; + +const compareNumbers = ( + a: number, + b: number, + direction: 'asc' | 'desc' +): number => { + const result = a - b; + + return direction === 'asc' ? result : -result; +}; + +export const sortPersonCredits = ( + credits: T[], + sort: PersonCreditSort, + groupKey: (credit: T) => string | number, + mergeGrouped: (objs: T[]) => T +): T[] => { + const grouped = groupBy(credits, groupKey); + const items = Object.values(grouped).map(mergeGrouped); + const [field, direction] = sort.split('.') as [string, 'asc' | 'desc']; + + return [...items].sort((a, b) => { + switch (field) { + case 'releaseDate': { + const aDate = getReleaseDate(a); + const bDate = getReleaseDate(b); + + if (!aDate && !bDate) { + return 0; + } + + if (!aDate) { + return 1; + } + + if (!bDate) { + return -1; + } + + return compareStrings(aDate, bDate, direction); + } + case 'title': + return compareStrings(getTitle(a), getTitle(b), direction); + case 'voteCount': + return compareNumbers(a.voteCount ?? 0, b.voteCount ?? 0, direction); + default: + return 0; + } + }); +}; diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index c8538793c2..aef02e9ab4 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -8,12 +8,20 @@ import TitleCard from '@app/components/TitleCard'; import globalMessages from '@app/i18n/globalMessages'; import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; -import { CircleStackIcon } from '@heroicons/react/24/solid'; +import { BarsArrowDownIcon, CircleStackIcon } from '@heroicons/react/24/solid'; import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces'; -import type { PersonDetails as PersonDetailsType } from '@server/models/Person'; -import { groupBy } from 'lodash'; +import type { + PersonCreditCast, + PersonCreditCrew, + PersonDetails as PersonDetailsType, +} from '@server/models/Person'; +import { + DEFAULT_PERSON_CREDIT_SORT, + sortPersonCredits, + type PersonCreditSort, +} from '@server/utils/personCreditHelpers'; import { useRouter } from 'next/router'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import TruncateMarkup from 'react-truncate-markup'; import useSWR from 'swr'; @@ -25,14 +33,34 @@ const messages = defineMessages('components.PersonDetails', { appearsin: 'Appearances', crewmember: 'Crew', ascharacter: 'as {character}', + sortReleaseDateAsc: 'Release Date Ascending', + sortReleaseDateDesc: 'Release Date Descending', + sortTitleAsc: 'Title (A-Z) Ascending', + sortTitleDesc: 'Title (Z-A) Descending', + sortVoteCountAsc: 'Vote Count Ascending', + sortVoteCountDesc: 'Vote Count Descending', }); +const SortOptions: Record = { + VoteCountDesc: 'voteCount.desc', + VoteCountAsc: 'voteCount.asc', + ReleaseDateDesc: 'releaseDate.desc', + ReleaseDateAsc: 'releaseDate.asc', + TitleAsc: 'title.asc', + TitleDesc: 'title.desc', +} as const; + type MediaType = 'all' | 'movie' | 'tv'; +const FILTER_SETTINGS_KEY = 'pd-filter-settings'; + const PersonDetails = () => { const intl = useIntl(); const router = useRouter(); - const [currentMediaType, setCurrentMediaType] = useState('all'); + const [currentMediaType, setCurrentMediaType] = useState('all'); + const [currentSort, setCurrentSort] = useState( + DEFAULT_PERSON_CREDIT_SORT + ); const { data, error } = useSWR( `/api/v1/person/${router.query.personId}` ); @@ -43,49 +71,67 @@ const PersonDetails = () => { `/api/v1/person/${router.query.personId}/combined_credits` ); + useEffect(() => { + const filterString = window.localStorage.getItem(FILTER_SETTINGS_KEY); + + if (!filterString) { + return; + } + + const filterSettings = JSON.parse(filterString); + + if (['all', 'movie', 'tv'].includes(filterSettings.currentMediaType)) { + setCurrentMediaType(filterSettings.currentMediaType); + } + + if (Object.values(SortOptions).includes(filterSettings.currentSort)) { + setCurrentSort(filterSettings.currentSort); + } + }, []); + + useEffect(() => { + window.localStorage.setItem( + FILTER_SETTINGS_KEY, + JSON.stringify({ + currentMediaType, + currentSort, + }) + ); + }, [currentMediaType, currentSort]); + const sortedCast = useMemo(() => { const filtered = (combinedCredits?.cast ?? []).filter( (media) => currentMediaType === 'all' || media.mediaType === currentMediaType ); - const grouped = groupBy(filtered, 'id'); - const reduced = Object.values(grouped).map((objs) => ({ - ...objs[0], - character: objs.map((pos) => pos.character).join(', '), - })); - - return reduced.sort((a, b) => { - const aVotes = a.voteCount ?? 0; - const bVotes = b.voteCount ?? 0; - if (aVotes > bVotes) { - return -1; - } - return 1; - }); - }, [combinedCredits, currentMediaType]); + return sortPersonCredits( + filtered, + currentSort, + (credit) => credit.id, + (objs) => ({ + ...objs[0], + character: objs.map((pos) => pos.character).join(', '), + }) + ); + }, [combinedCredits, currentMediaType, currentSort]); const sortedCrew = useMemo(() => { const filtered = (combinedCredits?.crew ?? []).filter( (media) => currentMediaType === 'all' || media.mediaType === currentMediaType ); - const grouped = groupBy(filtered, 'id'); - const reduced = Object.values(grouped).map((objs) => ({ - ...objs[0], - job: objs.map((pos) => pos.job).join(', '), - })); - - return reduced.sort((a, b) => { - const aVotes = a.voteCount ?? 0; - const bVotes = b.voteCount ?? 0; - if (aVotes > bVotes) { - return -1; - } - return 1; - }); - }, [combinedCredits, currentMediaType]); + return sortPersonCredits( + filtered, + currentSort, + (credit) => credit.id, + (objs) => ({ + ...objs[0], + job: objs.map((pos) => pos.job).join(', '), + }) + ); + }, [combinedCredits, currentMediaType, currentSort]); if (!data && !error) { return ; @@ -158,6 +204,49 @@ const PersonDetails = () => { ); + const sortPicker = ( +
+ + + + +
+ ); + + const filtersToolbar = ( +
+ {mediaTypePicker} + {sortPicker} +
+ ); + const cast = (sortedCast ?? []).length > 0 && ( <>
@@ -274,9 +363,7 @@ const PersonDetails = () => {

{data.name}

-
- {mediaTypePicker} -
+
{filtersToolbar}
@@ -302,7 +389,7 @@ const PersonDetails = () => {
)}
-
{mediaTypePicker}
+
{filtersToolbar}
{data.biography && (
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 21c9c8af16..ae865132ac 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -462,6 +462,12 @@ "components.PersonDetails.birthdate": "Born {birthdate}", "components.PersonDetails.crewmember": "Crew", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", + "components.PersonDetails.sortReleaseDateAsc": "Release Date Ascending", + "components.PersonDetails.sortReleaseDateDesc": "Release Date Descending", + "components.PersonDetails.sortTitleAsc": "Title (A-Z) Ascending", + "components.PersonDetails.sortTitleDesc": "Title (Z-A) Descending", + "components.PersonDetails.sortVoteCountAsc": "Vote Count Ascending", + "components.PersonDetails.sortVoteCountDesc": "Vote Count Descending", "components.QuotaSelector.days": "{count, plural, one {day} other {days}}", "components.QuotaSelector.movieRequests": "{quotaLimit} {movies} per {quotaDays} {days}", "components.QuotaSelector.movies": "{count, plural, one {movie} other {movies}}",