From ae60f81314b38dd16a215ba16b71ace2dcd9aa7d Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:32:16 -0800 Subject: [PATCH] Extracted DVR utils --- frontend/src/utils/dateTimeUtils.js | 123 +++++++++++++++++++++++++++ frontend/src/utils/pages/DVRUtils.js | 90 ++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 frontend/src/utils/dateTimeUtils.js create mode 100644 frontend/src/utils/pages/DVRUtils.js diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js new file mode 100644 index 00000000..cd497f59 --- /dev/null +++ b/frontend/src/utils/dateTimeUtils.js @@ -0,0 +1,123 @@ +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { + ActionIcon, + Box, + Button, + Card, + Center, + Flex, + Badge, + Group, + Image, + Modal, + SimpleGrid, + Stack, + Text, + Title, + Tooltip, + Switch, + Select, + MultiSelect, + TextInput, + useMantineTheme, +} from '@mantine/core'; +import { + AlertTriangle, + SquarePlus, + SquareX, +} from 'lucide-react'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import useChannelsStore from '../store/channels'; +import useSettingsStore from '../store/settings'; +import useLocalStorage from '../hooks/useLocalStorage'; +import useVideoStore from '../store/useVideoStore'; +import RecordingForm from '../components/forms/Recording'; +import { notifications } from '@mantine/notifications'; +import API from '../api'; +import { DatePickerInput, TimeInput } from '@mantine/dates'; +import { useForm } from '@mantine/form'; + +dayjs.extend(duration); +dayjs.extend(relativeTime); +dayjs.extend(utc); +dayjs.extend(timezone); + +export const useUserTimeZone = () => { + const settings = useSettingsStore((s) => s.settings); + const [timeZone, setTimeZone] = useLocalStorage( + 'time-zone', + dayjs.tz?.guess + ? dayjs.tz.guess() + : Intl.DateTimeFormat().resolvedOptions().timeZone + ); + + useEffect(() => { + const tz = settings?.['system-time-zone']?.value; + if (tz && tz !== timeZone) { + setTimeZone(tz); + } + }, [settings, timeZone, setTimeZone]); + + return timeZone; +}; + +export const useTimeHelpers = () => { + const timeZone = useUserTimeZone(); + + const toUserTime = useCallback( + (value) => { + if (!value) return dayjs.invalid(); + try { + return dayjs(value).tz(timeZone); + } catch (error) { + return dayjs(value); + } + }, + [timeZone] + ); + + const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]); + + return { timeZone, toUserTime, userNow }; +}; + +export const RECURRING_DAY_OPTIONS = [ + { value: 6, label: 'Sun' }, + { value: 0, label: 'Mon' }, + { value: 1, label: 'Tue' }, + { value: 2, label: 'Wed' }, + { value: 3, label: 'Thu' }, + { value: 4, label: 'Fri' }, + { value: 5, label: 'Sat' }, +]; + +export const useDateTimeFormat = () => { + const [timeFormatSetting] = useLocalStorage('time-format', '12h'); + const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); + // Use user preference for time format + const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm'; + const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM'; + + return [timeFormat, dateFormat] +}; + +export const toTimeString = (value) => { + if (!value) return '00:00'; + if (typeof value === 'string') { + const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true); + if (parsed.isValid()) return parsed.format('HH:mm'); + return value; + } + const parsed = dayjs(value); + return parsed.isValid() ? parsed.format('HH:mm') : '00:00'; +}; + +export const parseDate = (value) => { + if (!value) return null; + const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true); + return parsed.isValid() ? parsed.toDate() : null; +}; \ No newline at end of file diff --git a/frontend/src/utils/pages/DVRUtils.js b/frontend/src/utils/pages/DVRUtils.js new file mode 100644 index 00000000..139988d2 --- /dev/null +++ b/frontend/src/utils/pages/DVRUtils.js @@ -0,0 +1,90 @@ +// Deduplicate in-progress and upcoming by program id or channel+slot +const dedupeByProgramOrSlot = (arr) => { + const out = []; + const sigs = new Set(); + + for (const r of arr) { + const cp = r.custom_properties || {}; + const pr = cp.program || {}; + const sig = + pr?.id != null + ? `id:${pr.id}` + : `slot:${r.channel}|${r.start_time}|${r.end_time}|${pr.title || ''}`; + + if (sigs.has(sig)) continue; + sigs.add(sig); + out.push(r); + } + return out; +}; + +const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => { + // ID-based dedupe guard in case store returns duplicates + const seenIds = new Set(); + for (const rec of list) { + if (rec && rec.id != null) { + const k = String(rec.id); + if (seenIds.has(k)) continue; + seenIds.add(k); + } + + const s = toUserTime(rec.start_time); + const e = toUserTime(rec.end_time); + const status = rec.custom_properties?.status; + + if (status === 'interrupted' || status === 'completed') { + completed.push(rec); + } else { + if (now.isAfter(s) && now.isBefore(e)) inProgress.push(rec); + else if (now.isBefore(s)) upcoming.push(rec); + else completed.push(rec); + } + } +} + +export const categorizeRecordings = (recordings, toUserTime, now) => { + const inProgress = []; + const upcoming = []; + const completed = []; + const list = Array.isArray(recordings) + ? recordings + : Object.values(recordings || {}); + + dedupeById(list, toUserTime, completed, now, inProgress, upcoming); + + const inProgressDedup = dedupeByProgramOrSlot(inProgress).sort( + (a, b) => toUserTime(b.start_time) - toUserTime(a.start_time) + ); + + // Group upcoming by series title+tvg_id (keep only next episode) + const upcomingDedup = dedupeByProgramOrSlot(upcoming).sort( + (a, b) => toUserTime(a.start_time) - toUserTime(b.start_time) + ); + const grouped = new Map(); + + for (const rec of upcomingDedup) { + const cp = rec.custom_properties || {}; + const prog = cp.program || {}; + const key = `${prog.tvg_id || ''}|${(prog.title || '').toLowerCase()}`; + if (!grouped.has(key)) { + grouped.set(key, { rec, count: 1 }); + } else { + const entry = grouped.get(key); + entry.count += 1; + } + } + + const upcomingGrouped = Array.from(grouped.values()).map((e) => { + const item = { ...e.rec }; + item._group_count = e.count; + return item; + }); + + completed.sort((a, b) => toUserTime(b.end_time) - toUserTime(a.end_time)); + + return { + inProgress: inProgressDedup, + upcoming: upcomingGrouped, + completed, + }; +} \ No newline at end of file