Files
ersatztv/web/src/api/guide.ts
T
timothy a8d4979b0c
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(web): address Guide review feedback refs #85
2026-07-05 23:06:08 +02:00

160 lines
4.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { getChannelStates, messageFromError, type ChannelState } from './channels';
import { request } from './client';
import type { components } from './generated/v1';
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
export interface GuideScreenData {
channelStates: ChannelState[];
guide: ChannelGuide;
}
export type GuideScreenQueryState =
| {
data: GuideScreenData;
error: null;
refresh: () => void;
setWindowStart: (start: Date) => void;
status: 'success';
windowStart: Date;
windowEnd: Date;
}
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
type GuideScreenState =
| { data: GuideScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export const GUIDE_PAST_MS = 60 * 60 * 1000;
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
export function defaultGuideWindowStart(now = new Date()): Date {
return new Date(now.getTime() - GUIDE_PAST_MS);
}
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
const query = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
const guide = await getGuide(start, end);
const channelStates = await getChannelStates().catch(() => []);
return { channelStates, guide };
}
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
const windowStartRef = useRef(windowStart);
const [state, setState] = useState<GuideScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const requestIdRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
const load = useCallback((start = windowStartRef.current) => {
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
getGuideScreenData(start, end)
.then((data) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
})
.catch(() => {
// Keep the guide visible when the optional live-state refresh fails.
});
}, []);
useEffect(() => {
load(windowStart);
}, [load, windowStart]);
useEffect(() => {
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
return () => {
window.clearInterval(intervalId);
};
}, [channelStatePollMs, loadChannelStates]);
const setWindowStart = useCallback((start: Date) => {
windowStartRef.current = start;
setState({ data: null, error: null, status: 'loading' });
setWindowStartState(start);
}, []);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load(windowStartRef.current);
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
}
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
}