fix(portals): read the year with one rule everywhere

The shared detail path fed the chip `meta.year`, which the adapter built
with a fixed-prefix fallback: a day-first `31-03-1999` became `31-0`, so
that path both displayed the wrong label and lost the facet, since the
guard could not parse it back.

`parseFacetYear()` moves to shared/interfaces and both callers delegate
to it, so the adapters and the Discover chips cannot drift into
disagreeing about what a date says. The adapter keeps its date-parse
fallback for shapes stating no four-digit run, but no longer invents one
by slicing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-16 13:08:07 +02:00
1 parent 5534e51c8d
commit ec55e4910e
4 files changed
+105 -25

No files matched your search

@@ -4,7 +4,7 @@ import {
TmdbCountryFacet,
TmdbGenreFacet,
TmdbMediaType,
isTmdbYearFacet,
parseFacetYear,
} from '@iptvnator/shared/interfaces';
import { discoverLink } from './discover-link.util';
@@ -33,19 +33,6 @@ export interface DiscoverFacetNavigation {
openCountry(country: TmdbCountryFacet): void;
}
/**
* Year stated by a provider date field, whatever shape it arrives in —
* `1976`, `1999-03-31` and `31-03-1999` all resolve. Reads the first
* four-digit run rather than a fixed slice, which the day-first form
* would otherwise turn into `NaN`, and rejects the `0000-00-00`
* placeholder rather than offering a chip that filters by nothing.
*/
function facetYear(releaseDate: string | null | undefined): number | null {
const match = releaseDate?.match(/\d{4}/);
const year = match ? Number(match[0]) : null;
return year !== null && isTmdbYearFacet(year) ? year : null;
}
/**
* Shared behavior behind the clickable year/genre/country chips on every
* detail page. Call it from a field initializer (injection context).
@@ -80,16 +67,16 @@ export function createDiscoverFacetNavigation(
};
const canOpenYear = (releaseDate: string | null | undefined): boolean =>
target() !== null && facetYear(releaseDate) !== null;
target() !== null && parseFacetYear(releaseDate) !== null;
return {
yearLabel(releaseDate) {
const year = facetYear(releaseDate);
const year = parseFacetYear(releaseDate);
return year === null ? null : String(year);
},
canOpenYear,
openYear(releaseDate) {
const year = facetYear(releaseDate);
const year = parseFacetYear(releaseDate);
if (year !== null) {
navigate({ kind: 'year', year });
}
@@ -131,6 +131,23 @@ export function isTmdbYearFacet(year: number): boolean {
return Number.isInteger(year) && year >= 1000 && year <= 9999;
}
/**
* The year stated by a provider date field, whatever shape it arrives in
* — `1976`, `1999-03-31` and `31-03-1999` all resolve to the same year.
*
* Reads the first four-digit run rather than a fixed prefix: slicing
* turns a day-first date into `31-0`, which is both the wrong label and
* an unusable filter. Lives here so the Discover chips and the detail
* adapters cannot drift into disagreeing about what a date says.
*/
export function parseFacetYear(
releaseDate: string | null | undefined
): number | null {
const match = releaseDate?.match(/\d{4}/);
const year = match ? Number(match[0]) : null;
return year !== null && isTmdbYearFacet(year) ? year : null;
}
/**
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
* by the `lookupKey` prefix:
@@ -1,4 +1,75 @@
import { youtubeEmbedUrl } from './vod-details-adapters';
import {
normalizeStalkerVod,
normalizeXtreamVod,
youtubeEmbedUrl,
} from './vod-details-adapters';
describe('normalized release year', () => {
const stalker = (releasedate: string) =>
normalizeStalkerVod({
id: '1',
cmd: '',
info: {
movie_image: '',
description: '',
name: 'Film',
actors: '',
director: '',
releasedate,
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
}).year;
it('reads the year out of a day-first provider date', () => {
// A fixed prefix renders '31-0' here, which is both the wrong
// label and a year the Discover chip cannot filter by
expect(stalker('31-03-1999')).toBe('1999');
});
it('keeps reading ordinary date shapes', () => {
expect(stalker('1976')).toBe('1976');
expect(stalker('1999-03-31')).toBe('1999');
expect(
normalizeXtreamVod({
info: {
kinopoisk_url: '',
tmdb_id: 0,
name: 'Film',
o_name: '',
cover_big: '',
movie_image: '',
releasedate: '2018-05-01',
episode_run_time: 0,
youtube_trailer: '',
director: '',
actors: '',
cast: '',
description: '',
plot: '',
age: '',
mpaa_rating: '',
rating_count_kinopoisk: 0,
country: '',
genre: '',
backdrop_path: [],
duration_secs: 0,
duration: '',
video: [],
audio: [],
bitrate: 0,
rating: 0,
},
}).year
).toBe('2018');
});
it('states no year for a placeholder or unusable date', () => {
expect(stalker('0000-00-00')).toBeUndefined();
expect(stalker('')).toBeUndefined();
});
});
describe('youtubeEmbedUrl', () => {
it('builds an embed URL from a plain video id (TMDB format)', () => {
@@ -1,5 +1,6 @@
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import { parseFacetYear } from './tmdb.interface';
import {
NormalizedVodMeta,
VodDetailsItem,
@@ -170,24 +171,28 @@ export function getVodNumericId(item: VodDetailsItem): number {
// ============ Helper Functions ============
/**
* Extracts 4-digit year from various date formats.
* Extracts the 4-digit year from the various date formats providers send.
*
* Delegates to the shared facet parser so the displayed year and the
* Discover chip built from it cannot disagree — the old fixed-prefix
* fallback turned a day-first `31-03-1999` into `31-0`, which is both
* the wrong label and an unusable filter. The date-parse fallback stays
* for shapes stating no four-digit run of their own.
*/
function extractYear(dateString?: string): string | undefined {
if (!dateString) return undefined;
// Try to extract 4-digit year from beginning
const yearMatch = dateString.match(/^(\d{4})/);
if (yearMatch) {
return yearMatch[1];
const facetYear = parseFacetYear(dateString);
if (facetYear !== null) {
return String(facetYear);
}
// Try to parse as date and extract year
const date = new Date(dateString);
if (!isNaN(date.getTime())) {
return date.getFullYear().toString();
}
return dateString.slice(0, 4);
return undefined;
}
/**