From 23162a86f7d1ab8a5612fdba39c61951a2f14ab7 Mon Sep 17 00:00:00 2001 From: Maxime <670701+Maxwell2022@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:47:49 +1000 Subject: [PATCH 1/3] feat(person): add sort controls to person credits page Add a client-side sort dropdown on the person page so cast and crew credits can be ordered by release date, popularity, rating, title, or vote count. Defaults to newest release date first. Co-authored-by: Cursor --- src/components/PersonDetails/index.tsx | 185 +++++++++++++++++++------ src/i18n/locale/en.json | 10 ++ src/utils/personCreditHelpers.ts | 94 +++++++++++++ 3 files changed, 250 insertions(+), 39 deletions(-) create mode 100644 src/utils/personCreditHelpers.ts diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index c8538793c2..9af5d5f0aa 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 { + DEFAULT_PERSON_CREDIT_SORT, + sortPersonCredits, + type PersonCreditSort, +} from '@app/utils/personCreditHelpers'; +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 { 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,42 @@ const messages = defineMessages('components.PersonDetails', { appearsin: 'Appearances', crewmember: 'Crew', ascharacter: 'as {character}', + sortPopularityAsc: 'Popularity Ascending', + sortPopularityDesc: 'Popularity Descending', + sortReleaseDateAsc: 'Release Date Ascending', + sortReleaseDateDesc: 'Release Date Descending', + sortTmdbRatingAsc: 'TMDB Rating Ascending', + sortTmdbRatingDesc: 'TMDB Rating Descending', + sortTitleAsc: 'Title (A-Z) Ascending', + sortTitleDesc: 'Title (Z-A) Descending', + sortVoteCountAsc: 'Vote Count Ascending', + sortVoteCountDesc: 'Vote Count Descending', }); +const SortOptions: Record = { + ReleaseDateDesc: 'releaseDate.desc', + ReleaseDateAsc: 'releaseDate.asc', + PopularityDesc: 'popularity.desc', + PopularityAsc: 'popularity.asc', + TmdbRatingDesc: 'voteAverage.desc', + TmdbRatingAsc: 'voteAverage.asc', + TitleAsc: 'title.asc', + TitleDesc: 'title.desc', + VoteCountDesc: 'voteCount.desc', + VoteCountAsc: 'voteCount.asc', +} 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 +79,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 +212,61 @@ const PersonDetails = () => { ); + const sortPicker = ( +
+ + + + +
+ ); + + const filtersToolbar = ( +
+ {mediaTypePicker} + {sortPicker} +
+ ); + const cast = (sortedCast ?? []).length > 0 && ( <>
@@ -274,9 +383,7 @@ const PersonDetails = () => {

{data.name}

-
- {mediaTypePicker} -
+
{filtersToolbar}
@@ -302,7 +409,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..398944caa7 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -462,6 +462,16 @@ "components.PersonDetails.birthdate": "Born {birthdate}", "components.PersonDetails.crewmember": "Crew", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", + "components.PersonDetails.sortPopularityAsc": "Popularity Ascending", + "components.PersonDetails.sortPopularityDesc": "Popularity Descending", + "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.sortTmdbRatingAsc": "TMDB Rating Ascending", + "components.PersonDetails.sortTmdbRatingDesc": "TMDB Rating 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}}", diff --git a/src/utils/personCreditHelpers.ts b/src/utils/personCreditHelpers.ts new file mode 100644 index 0000000000..6342e19cee --- /dev/null +++ b/src/utils/personCreditHelpers.ts @@ -0,0 +1,94 @@ +import type { PersonCredit } from '@server/models/Person'; +import { groupBy } from 'lodash'; + +export type PersonCreditSort = + | 'releaseDate.desc' + | 'releaseDate.asc' + | 'popularity.desc' + | 'popularity.asc' + | 'voteAverage.desc' + | 'voteAverage.asc' + | 'title.asc' + | 'title.desc' + | 'voteCount.desc' + | 'voteCount.asc'; + +export const DEFAULT_PERSON_CREDIT_SORT: PersonCreditSort = 'releaseDate.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 'popularity': + return compareNumbers(a.popularity ?? 0, b.popularity ?? 0, direction); + case 'voteAverage': + return compareNumbers( + a.voteAverage ?? 0, + b.voteAverage ?? 0, + direction + ); + case 'voteCount': + return compareNumbers(a.voteCount ?? 0, b.voteCount ?? 0, direction); + default: + return 0; + } + }); +}; From 27933f7204897969b8a33c4dc8b0f67763c28b23 Mon Sep 17 00:00:00 2001 From: Maxime <670701+Maxwell2022@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:57:51 +1000 Subject: [PATCH 2/3] refactor(person): limit credit sort options and restore vote count default Drop popularity and TMDB rating sorts that produced misleading person filmography order. Default back to vote count descending to match the previous implicit behavior. Co-authored-by: Cursor --- src/components/PersonDetails/index.tsx | 36 ++++++-------------------- src/i18n/locale/en.json | 4 --- src/utils/personCreditHelpers.ts | 14 +--------- 3 files changed, 9 insertions(+), 45 deletions(-) diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 9af5d5f0aa..93a7d2b36d 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -33,12 +33,8 @@ const messages = defineMessages('components.PersonDetails', { appearsin: 'Appearances', crewmember: 'Crew', ascharacter: 'as {character}', - sortPopularityAsc: 'Popularity Ascending', - sortPopularityDesc: 'Popularity Descending', sortReleaseDateAsc: 'Release Date Ascending', sortReleaseDateDesc: 'Release Date Descending', - sortTmdbRatingAsc: 'TMDB Rating Ascending', - sortTmdbRatingDesc: 'TMDB Rating Descending', sortTitleAsc: 'Title (A-Z) Ascending', sortTitleDesc: 'Title (Z-A) Descending', sortVoteCountAsc: 'Vote Count Ascending', @@ -46,16 +42,12 @@ const messages = defineMessages('components.PersonDetails', { }); const SortOptions: Record = { + VoteCountDesc: 'voteCount.desc', + VoteCountAsc: 'voteCount.asc', ReleaseDateDesc: 'releaseDate.desc', ReleaseDateAsc: 'releaseDate.asc', - PopularityDesc: 'popularity.desc', - PopularityAsc: 'popularity.asc', - TmdbRatingDesc: 'voteAverage.desc', - TmdbRatingAsc: 'voteAverage.asc', TitleAsc: 'title.asc', TitleDesc: 'title.desc', - VoteCountDesc: 'voteCount.desc', - VoteCountAsc: 'voteCount.asc', } as const; type MediaType = 'all' | 'movie' | 'tv'; @@ -226,36 +218,24 @@ const PersonDetails = () => { value={currentSort} className="rounded-r-only" > + + - - - - - -
); diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 398944caa7..ae865132ac 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -462,14 +462,10 @@ "components.PersonDetails.birthdate": "Born {birthdate}", "components.PersonDetails.crewmember": "Crew", "components.PersonDetails.lifespan": "{birthdate} – {deathdate}", - "components.PersonDetails.sortPopularityAsc": "Popularity Ascending", - "components.PersonDetails.sortPopularityDesc": "Popularity Descending", "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.sortTmdbRatingAsc": "TMDB Rating Ascending", - "components.PersonDetails.sortTmdbRatingDesc": "TMDB Rating Descending", "components.PersonDetails.sortVoteCountAsc": "Vote Count Ascending", "components.PersonDetails.sortVoteCountDesc": "Vote Count Descending", "components.QuotaSelector.days": "{count, plural, one {day} other {days}}", diff --git a/src/utils/personCreditHelpers.ts b/src/utils/personCreditHelpers.ts index 6342e19cee..bf2e7fbe31 100644 --- a/src/utils/personCreditHelpers.ts +++ b/src/utils/personCreditHelpers.ts @@ -4,16 +4,12 @@ import { groupBy } from 'lodash'; export type PersonCreditSort = | 'releaseDate.desc' | 'releaseDate.asc' - | 'popularity.desc' - | 'popularity.asc' - | 'voteAverage.desc' - | 'voteAverage.asc' | 'title.asc' | 'title.desc' | 'voteCount.desc' | 'voteCount.asc'; -export const DEFAULT_PERSON_CREDIT_SORT: PersonCreditSort = 'releaseDate.desc'; +export const DEFAULT_PERSON_CREDIT_SORT: PersonCreditSort = 'voteCount.desc'; const getReleaseDate = (credit: PersonCredit): string => credit.mediaType === 'movie' ? credit.releaseDate : credit.firstAirDate; @@ -77,14 +73,6 @@ export const sortPersonCredits = ( } case 'title': return compareStrings(getTitle(a), getTitle(b), direction); - case 'popularity': - return compareNumbers(a.popularity ?? 0, b.popularity ?? 0, direction); - case 'voteAverage': - return compareNumbers( - a.voteAverage ?? 0, - b.voteAverage ?? 0, - direction - ); case 'voteCount': return compareNumbers(a.voteCount ?? 0, b.voteCount ?? 0, direction); default: From a10d986cc08fd0103cee6c98e469891b1982d880 Mon Sep 17 00:00:00 2001 From: Maxime <670701+Maxwell2022@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:00:04 +1000 Subject: [PATCH 3/3] test(person): add unit tests for credit sort helper Move sortPersonCredits to server/utils so it can be covered by the existing node:test suite. Cover vote count, release date, title sorting, missing dates, and grouping duplicate credits. Co-authored-by: Cursor --- server/utils/personCreditHelpers.test.ts | 159 +++++++++++++++++++ {src => server}/utils/personCreditHelpers.ts | 0 src/components/PersonDetails/index.tsx | 10 +- 3 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 server/utils/personCreditHelpers.test.ts rename {src => server}/utils/personCreditHelpers.ts (100%) 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/src/utils/personCreditHelpers.ts b/server/utils/personCreditHelpers.ts similarity index 100% rename from src/utils/personCreditHelpers.ts rename to server/utils/personCreditHelpers.ts diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index 93a7d2b36d..aef02e9ab4 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -8,11 +8,6 @@ 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 { - DEFAULT_PERSON_CREDIT_SORT, - sortPersonCredits, - type PersonCreditSort, -} from '@app/utils/personCreditHelpers'; import { BarsArrowDownIcon, CircleStackIcon } from '@heroicons/react/24/solid'; import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces'; import type { @@ -20,6 +15,11 @@ import type { 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 { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl';