Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions api/episodes.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getUpcomingEpisodes, toIcs } from '../lib/tvEpisodes.js';
import { getEpisodes, toIcs } from '../lib/tvEpisodes.js';

function sendJson(res, statusCode, payload) {
res.statusCode = statusCode;
Expand All @@ -17,9 +17,10 @@ export default async function handler(req, res) {
const query = requestUrl.searchParams.get('show') || requestUrl.searchParams.get('q');
const format = requestUrl.searchParams.get('format');
const timezone = requestUrl.searchParams.get('tz') || 'UTC';
const since = requestUrl.searchParams.get('since');

try {
const result = await getUpcomingEpisodes({ query });
const result = await getEpisodes({ query, since });

if (format === 'ics') {
res.statusCode = 200;
Expand All @@ -32,7 +33,7 @@ export default async function handler(req, res) {
} catch (error) {
const statusCode = error.statusCode || (error.message?.includes('404') ? 404 : 500);
return sendJson(res, statusCode, {
error: statusCode === 404 ? 'Show not found.' : error.message || 'Unable to fetch upcoming episodes.'
error: statusCode === 404 ? 'Show not found.' : error.message || 'Unable to fetch episodes.'
});
}
}
5 changes: 3 additions & 2 deletions api/sports-events.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getUpcomingEvents, toIcs } from '../lib/sports.js';
import { getEvents, toIcs } from '../lib/sports.js';

function sendJson(res, statusCode, payload) {
res.statusCode = statusCode;
Expand All @@ -17,13 +17,14 @@ export default async function handler(req, res) {
const teamId = requestUrl.searchParams.get('teamId');
const format = requestUrl.searchParams.get('format');
const timezone = requestUrl.searchParams.get('tz') || 'UTC';
const since = requestUrl.searchParams.get('since');

if (!teamId) {
return sendJson(res, 400, { error: 'teamId is required.' });
}

try {
const result = await getUpcomingEvents({ teamId });
const result = await getEvents({ teamId, since });

if (format === 'ics') {
res.statusCode = 200;
Expand Down
25 changes: 14 additions & 11 deletions lib/sports.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ async function loadSupplementalTeamEvents(teamId) {
}
}

export async function getUpcomingEvents({ teamId, fetchImpl = globalThis.fetch } = {}) {
export async function getEvents({ teamId, now = new Date(), since = null, fetchImpl = globalThis.fetch } = {}) {
if (!teamId) {
throw new Error('A team ID is required.');
}
Expand Down Expand Up @@ -319,23 +319,26 @@ export async function getUpcomingEvents({ teamId, fetchImpl = globalThis.fetch }
}
}

// Filter for upcoming events (include ongoing events started in the last 3 hours)
const now = new Date();
const threeHoursAgo = new Date(now.getTime() - 180 * 60 * 1000);
const upcomingEvents = uniqueEvents
.filter((event) => {
const eventDate = parseApiTimestamp(event.timestamp, event.date, event.time);
return eventDate >= threeHoursAgo;
})
let sortedEvents = uniqueEvents
.sort((a, b) => {
const dateA = parseApiTimestamp(a.timestamp, a.date, a.time);
const dateB = parseApiTimestamp(b.timestamp, b.date, b.time);
return dateA - dateB;
});

if (since) {
const sinceTime = Date.parse(since);
if (!Number.isNaN(sinceTime)) {
sortedEvents = sortedEvents.filter((e) => {
const timestamp = parseApiTimestamp(e.timestamp, e.date, e.time);
return timestamp.getTime() >= sinceTime;
});
}
}

return {
team: normalizeTeamSuggestion(team),
events: upcomingEvents,
events: sortedEvents,
generatedAt: now.toISOString()
};
}
Expand All @@ -354,7 +357,7 @@ export function toIcs(result, { timezone = 'UTC' } = {}) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//MakeICS//Sports Upcoming Events//EN',
'PRODID:-//MakeICS//Sports Events//EN',
'CALSCALE:GREGORIAN',
`X-PUBLISHED-TTL:${FEED_REFRESH_INTERVAL}`,
`REFRESH-INTERVAL;VALUE=DURATION:${FEED_REFRESH_INTERVAL}`
Expand Down
23 changes: 14 additions & 9 deletions lib/tvEpisodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,8 @@ function normalizeEpisode(episode, show) {
};
}

function filterUpcomingEpisodes(episodes, show, now) {
const from = now.getTime();

function normalizeEpisodes(episodes, show) {
return episodes
.filter((episode) => getEpisodeTimestamp(episode) >= from)
.sort((left, right) => getEpisodeTimestamp(left) - getEpisodeTimestamp(right))
.map((episode) => normalizeEpisode(episode, show));
}
Expand Down Expand Up @@ -320,7 +317,7 @@ async function fetchImdbDetails(imdbId, env, fetchImpl) {
}
}

export async function getUpcomingEpisodes({ query, now = new Date(), fetchImpl = globalThis.fetch, env = process.env } = {}) {
export async function getEpisodes({ query, now = new Date(), since = null, fetchImpl = globalThis.fetch, env = process.env } = {}) {
const trimmedQuery = typeof query === 'string' ? query.trim() : '';
if (!trimmedQuery) {
const error = new Error('A show name is required.');
Expand All @@ -334,7 +331,15 @@ export async function getUpcomingEpisodes({ query, now = new Date(), fetchImpl =

const show = await searchShow(trimmedQuery, fetchImpl);
const episodes = await fetchShowEpisodes(show.id, fetchImpl);
const upcomingEpisodes = filterUpcomingEpisodes(episodes, show, now);
let allEpisodes = normalizeEpisodes(episodes, show).filter((e) => e.airstamp || e.airdate);

if (since) {
const sinceTime = Date.parse(since);
if (!Number.isNaN(sinceTime)) {
allEpisodes = allEpisodes.filter((e) => getEpisodeTimestamp(e) >= sinceTime);
}
}

const imdbId = show.externals?.imdb || null;
const imdb = await fetchImdbDetails(imdbId, env, fetchImpl);

Expand All @@ -361,7 +366,7 @@ export async function getUpcomingEpisodes({ query, now = new Date(), fetchImpl =
network: show.network?.name || show.webChannel?.name || null
},
imdb,
episodes: upcomingEpisodes
episodes: allEpisodes
};
}

Expand All @@ -383,7 +388,7 @@ export function toIcs(result, { timezone = 'UTC' } = {}) {
details.push(`Time: ${episodeTime}.`);
}

details.push(episode.summary || `Upcoming episode of ${episode.showName}`);
details.push(episode.summary || `Episode of ${episode.showName}`);
return details.join(' ');
};

Expand All @@ -394,7 +399,7 @@ export function toIcs(result, { timezone = 'UTC' } = {}) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//MakeICS//TV Upcoming Episodes//EN',
'PRODID:-//MakeICS//TV Episodes//EN',
'CALSCALE:GREGORIAN',
`X-PUBLISHED-TTL:${FEED_REFRESH_INTERVAL}`,
`REFRESH-INTERVAL;VALUE=DURATION:${FEED_REFRESH_INTERVAL}`
Expand Down
18 changes: 10 additions & 8 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ let suggestionAbortController;
let activeSuggestionIndex = -1;

const CATEGORY_HINTS = {
tv: 'Start typing to see TV show suggestions below the search bar. The all-time feed uses TVMaze for show and episode schedules.',
sports: 'Search for sports teams from TheSportsDB. Copy the ICS URL to track upcoming matches.'
tv: 'Start typing to see TV show suggestions below the search bar. The feed includes all episodes from today onwards once added.',
sports: 'Search for sports teams from TheSportsDB. Copy the ICS URL to track matches from today onwards once added.'
};

// --- Tab Logic ---
Expand Down Expand Up @@ -225,10 +225,11 @@ function formatAirDate(dateStr, timeStr, timestamp, includeZones = false, timezo
function icsUrlForCurrent() {
let path = '';
const tz = timezoneInput?.value || 'UTC';
const since = new Date().toISOString().split('T')[0];
if (currentCategory === 'tv') {
path = `/api/episodes?show=${encodeURIComponent(showInput.value)}&format=ics&tz=${encodeURIComponent(tz)}`;
path = `/api/episodes?show=${encodeURIComponent(showInput.value)}&format=ics&tz=${encodeURIComponent(tz)}&since=${since}`;
} else if (currentCategory === 'sports') {
path = `/api/sports-events?teamId=${encodeURIComponent(sportsInput.dataset.teamId)}&format=ics&tz=${encodeURIComponent(tz)}`;
path = `/api/sports-events?teamId=${encodeURIComponent(sportsInput.dataset.teamId)}&format=ics&tz=${encodeURIComponent(tz)}&since=${since}`;
}
return new URL(path, window.location.origin).href;
}
Expand Down Expand Up @@ -389,8 +390,8 @@ function renderList(items, type, context, timezone = 'UTC') {
const count = document.createElement('p');
count.className = 'count';
count.textContent = items.length
? `${items.length} upcoming event${items.length === 1 ? '' : 's'} found.`
: `No upcoming events found.`;
? `${items.length} event${items.length === 1 ? '' : 's'} found.`
: `No events found.`;
resultEl.append(count);

const list = document.createElement('div');
Expand Down Expand Up @@ -494,17 +495,18 @@ form.addEventListener('submit', async (event) => {
let label = '';
const tz = timezoneInput?.value || 'UTC';

const since = new Date().toISOString().split('T')[0];
if (currentCategory === 'tv') {
label = showInput.value;
url = `/api/episodes?show=${encodeURIComponent(label)}&tz=${encodeURIComponent(tz)}`;
url = `/api/episodes?show=${encodeURIComponent(label)}&tz=${encodeURIComponent(tz)}&since=${since}`;
} else if (currentCategory === 'sports') {
const teamId = sportsInput.dataset.teamId;
if (!teamId || teamId === 'undefined') {
setStatus('Please select a team from the suggestions.', true);
return;
}
label = sportsInput.value;
url = `/api/sports-events?teamId=${encodeURIComponent(teamId)}&tz=${encodeURIComponent(tz)}`;
url = `/api/sports-events?teamId=${encodeURIComponent(teamId)}&tz=${encodeURIComponent(tz)}&since=${since}`;
}

setStatus(`Fetching for ${label}...`);
Expand Down
1 change: 0 additions & 1 deletion server_output.log

This file was deleted.

26 changes: 13 additions & 13 deletions test/sports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { searchTeamSuggestions, getUpcomingEvents, toIcs } from '../lib/sports.js';
import { searchTeamSuggestions, getEvents, toIcs } from '../lib/sports.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = path.join(__dirname, '../lib/data/sports');
Expand Down Expand Up @@ -128,27 +128,27 @@ test('searchTeamSuggestions returns team results', async () => {
assert.equal(suggestions[0].name, 'Arsenal');
});

test('getUpcomingEvents returns merged and deduplicated events for a team', (t) => {
test('getEvents returns merged and deduplicated events for a team, including past ones', (t) => {
const clock = t.mock.timers;
clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') });
clock.enable({ names: ['Date'], now: new Date('2027-01-01T00:00:00Z') });

return t.test('merging logic', async () => {
const result = await getUpcomingEvents({
const result = await getEvents({
teamId: '133604',
fetchImpl: createFetchMock()
});

assert.equal(result.team.name, 'Arsenal');
// Should have: Arsenal vs Real Betis (4), Arsenal vs Everton (1), Chelsea vs Arsenal (3)
// Deduplicated and sorted by date
// Deduplicated and sorted by date, all are in the past now but should be included
assert.equal(result.events.length, 3);
assert.equal(result.events[0].id, '4');
assert.equal(result.events[1].id, '1');
assert.equal(result.events[2].id, '3');
});
});

test('getUpcomingEvents utilizes local cache if available', async (t) => {
test('getEvents utilizes local cache if available', async (t) => {
const leagueId = '4328';
const cacheFilePath = path.join(CACHE_DIR, `${leagueId}.json`);
const cachedData = {
Expand Down Expand Up @@ -180,7 +180,7 @@ test('getUpcomingEvents utilizes local cache if available', async (t) => {
const onCall = (url) => calls.push(url);

try {
const result = await getUpcomingEvents({
const result = await getEvents({
teamId: '133604',
fetchImpl: createFetchMock(onCall)
});
Expand All @@ -197,7 +197,7 @@ test('getUpcomingEvents utilizes local cache if available', async (t) => {
}
});

test('getUpcomingEvents merges supplemental (scraped) data', async (t) => {
test('getEvents merges supplemental (scraped) data', async (t) => {
const clock = t.mock.timers;
clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') });

Expand Down Expand Up @@ -229,7 +229,7 @@ test('getUpcomingEvents merges supplemental (scraped) data', async (t) => {
await fs.writeFile(supplementalFilePath, JSON.stringify(scrapedData));

try {
const result = await getUpcomingEvents({
const result = await getEvents({
teamId,
fetchImpl: createFetchMock()
});
Expand All @@ -247,7 +247,7 @@ test('toIcs creates ICS for sports events', async (t) => {
const clock = t.mock.timers;
clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') });

const result = await getUpcomingEvents({
const result = await getEvents({
teamId: '133604',
fetchImpl: createFetchMock()
});
Expand All @@ -260,7 +260,7 @@ test('toIcs creates ICS for sports events', async (t) => {
assert.match(ics, /LOCATION:Emirates Stadium/);
});

test('getUpcomingEvents handles strTimestamp with offset correctly', async (t) => {
test('getEvents handles strTimestamp with offset correctly', async (t) => {
const clock = t.mock.timers;
clock.enable({ names: ['Date'], now: new Date('2026-01-01T00:00:00Z') });

Expand All @@ -284,12 +284,12 @@ test('getUpcomingEvents handles strTimestamp with offset correctly', async (t) =
const fetchImpl = async (url) => {
if (url.includes('lookupteam.php')) return Response.json(teamPayload);
if (url.includes('eventsnext.php')) return Response.json(customEventsPayload);
// Return empty for other calls to avoid errors in getUpcomingEvents
// Return empty for other calls to avoid errors in getEvents
if (url.includes('lookupleague.php')) return Response.json({ leagues: [] });
return Response.json({});
};

const result = await getUpcomingEvents({
const result = await getEvents({
teamId: '133604',
fetchImpl
});
Expand Down
Loading