diff --git a/src/lib/components/chart.svelte b/src/lib/components/chart.svelte index 83da75c..71fcf3f 100644 --- a/src/lib/components/chart.svelte +++ b/src/lib/components/chart.svelte @@ -21,6 +21,8 @@ chart: { type: "area", backgroundColor: "transparent", + height: 320, + spacing: [12, 8, 8, 8], zooming: { type: "x", }, @@ -116,6 +118,10 @@ shared: true, shadow: false, formatter: function (this: any): any { + if (!this.points?.length) { + return ""; + } + return `${moment( this.points[0].x ).format("YYYY-MM-DD HH:mm:ss")}
${this.points @@ -134,6 +140,7 @@ plotOptions: { series: { threshold: null, + connectNulls: false, }, area: { lineColor: "#2662D9", @@ -165,6 +172,8 @@ text: title, style: { color: "white", + fontSize: "16px", + fontWeight: "600", }, }, series: series.map((series, index) => @@ -195,7 +204,7 @@ {#if browser} -
+
{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2c47c11..90cc3b5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,6 +6,6 @@ -
+
{@render children()}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b6e0147..450c09f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,7 +2,6 @@ import { env } from "$env/dynamic/public"; import { onMount } from "svelte"; import { z } from "zod"; - import { useSearchParams } from "runed/kit"; import * as Tabs from "$lib/components/ui/tabs"; import { Input } from "$lib/components/ui/input"; import { Button, buttonVariants } from "$lib/components/ui/button"; @@ -23,16 +22,11 @@ }, ] as const; - const schema = z.object({ - type: z.enum(["channel", "video"]).default("channel"), - source: z.enum(sources.map((source) => source.value)).default("vidiq"), - id: z.string().default(""), - }); + const resourceTypes = ["channel", "video"] as const; - const params = useSearchParams(schema); + type ResourceType = (typeof resourceTypes)[number]; + type Source = (typeof sources)[number]["value"]; - let status = $state.raw<"idle" | "loading" | "error">("idle"); - let error = $state.raw(""); type ChannelSnapshot = { date: string; subscribers: number | null; @@ -50,17 +44,43 @@ type Snapshot = ChannelSnapshot | VideoSnapshot; type StatKey = Exclude; type ExportPeriod = "all" | "hourly" | "daily" | "weekly" | "monthly"; + type LoadStatus = "idle" | "loading" | "error"; + type Stat = { + name: string; + key: StatKey; + }; + type ResourceState = { + draftId: string; + submittedId: string; + data: Snapshot[]; + status: LoadStatus; + error: string; + }; - let data = $state.raw([]); + let resources = $state>({ + channel: { + draftId: "", + submittedId: "", + data: [], + status: "idle", + error: "", + }, + video: { + draftId: "", + submittedId: "", + data: [], + status: "idle", + error: "", + }, + }); + + let type = $state.raw("channel"); + let source = $state.raw("vidiq"); let flipRowsAndColumns = $state.raw(false); let interpolateMissingColumns = $state.raw(false); - - let stats = $state.raw< - { - name: string; - key: StatKey; - }[] - >([]); + let lastSource = $state.raw("vidiq"); + let lastType = $state.raw("channel"); + const requestControllers: Partial> = {}; const exportPeriods: { label: string; value: ExportPeriod }[] = [ { label: "All Data", value: "all" }, @@ -91,7 +111,7 @@ const successResponseSchema = z.object({ ok: z.literal(true), site: z.string(), - type: z.enum(["channel", "video"]), + type: z.enum(resourceTypes), id: z.string(), data: z.unknown(), }); @@ -105,30 +125,7 @@ }), }); - const setError = (err: string) => { - error = err; - status = "error"; - }; - - const clearError = () => { - error = ""; - status = "idle"; - }; - - const getApiUrl = () => { - const baseUrl = env.PUBLIC_API_BASE_URL?.trim(); - - if (!baseUrl) { - throw new Error("Missing `PUBLIC_API_BASE_URL`."); - } - - return new URL( - `/api/${params.source}/${type}/${encodeURIComponent(params.id)}`, - baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/` - ); - }; - - const getStatsForType = (resourceType: typeof params.type) => { + const getStatsForType = (resourceType: ResourceType): Stat[] => { switch (resourceType) { case "channel": return [ @@ -144,10 +141,7 @@ name: "Videos", key: "videos", }, - ] satisfies { - name: string; - key: StatKey; - }[]; + ]; case "video": return [ { @@ -162,105 +156,279 @@ name: "Comments", key: "comments", }, - ] satisfies { - name: string; - key: StatKey; - }[]; + ]; } }; - async function fetchData() { - clearError(); - data = []; - stats = []; + let activeResource = $derived(resources[type]); + let stats = $derived(getStatsForType(type)); + let selectTriggerContent = $derived( + sources.find((item) => item.value === source)?.name + ); + let hasLoadedData = $derived( + activeResource.status !== "loading" && activeResource.data.length > 0 + ); + + const capitalize = (str: string) => + str[0].toUpperCase() + str.slice(1).toLowerCase(); + + const getTypeIdParam = (resourceType: ResourceType) => + resourceType === "channel" ? "channelId" : "videoId"; + + const parseResourceType = (value: string | null): ResourceType | null => + value === "channel" || value === "video" ? value : null; + + const parseSource = (value: string | null): Source => + sources.some((item) => item.value === value) ? (value as Source) : "vidiq"; + + const getUrlState = () => { + const url = new URL(window.location.href); + const explicitType = parseResourceType(url.searchParams.get("type")); + const channelId = url.searchParams.get("channelId")?.trim() ?? ""; + const videoId = url.searchParams.get("videoId")?.trim() ?? ""; + const legacyId = url.searchParams.get("id")?.trim() ?? ""; + const resourceType = explicitType ?? (videoId ? "video" : "channel"); + + return { + source: parseSource(url.searchParams.get("source")), + type: resourceType, + channelId: channelId || (resourceType === "channel" ? legacyId : ""), + videoId: videoId || (resourceType === "video" ? legacyId : ""), + }; + }; - if (!params.id.length) { - setError(`No ID provided. Please input a ${type} ID.`); + const setExclusiveUrlId = (resourceType: ResourceType, id: string) => { + if (typeof window === "undefined") { return; } - status = "loading"; + const url = new URL(window.location.href); + url.search = ""; + + if (source !== "vidiq") { + url.searchParams.set("source", source); + } + + if (id) { + url.searchParams.set(getTypeIdParam(resourceType), id); + } else if (resourceType === "video") { + url.searchParams.set("type", "video"); + } + + window.history.replaceState(window.history.state, "", url); + }; + + const syncUrlToActiveType = (resourceType: ResourceType) => { + setExclusiveUrlId(resourceType, resources[resourceType].submittedId.trim()); + }; + + const setResourceError = (resourceType: ResourceType, err: string) => { + resources[resourceType].error = err; + resources[resourceType].status = "error"; + }; + + const clearResourceMessage = (resourceType: ResourceType) => { + resources[resourceType].error = ""; + if (resources[resourceType].status === "error") { + resources[resourceType].status = "idle"; + } + }; + + const resetResourceData = (resourceType: ResourceType) => { + requestControllers[resourceType]?.abort(); + clearResourceMessage(resourceType); + resources[resourceType].data = []; + }; + + const resetAllLoadedData = () => { + for (const resourceType of resourceTypes) { + resetResourceData(resourceType); + resources[resourceType].submittedId = ""; + } + }; + + const getApiUrl = (resourceType: ResourceType, id: string) => { + const baseUrl = env.PUBLIC_API_BASE_URL?.trim(); + + if (!baseUrl) { + throw new Error("Missing `PUBLIC_API_BASE_URL`."); + } + + return new URL( + `/api/${source}/${resourceType}/${encodeURIComponent(id)}`, + baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/` + ); + }; + + const getStatValue = (snapshot: Snapshot, key: StatKey) => { + if (key in snapshot) { + return snapshot[key as keyof typeof snapshot] as number | null; + } + + return null; + }; + + const getSnapshotTime = (snapshot: Snapshot) => + new Date(snapshot.date).getTime(); + + const isValidSnapshot = (snapshot: Snapshot) => + Number.isFinite(getSnapshotTime(snapshot)); + + const sortSnapshots = (snapshots: Snapshot[]) => + snapshots + .filter(isValidSnapshot) + .map((snapshot) => ({ ...snapshot })) + .sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b)); + + async function fetchData(resourceType: ResourceType, requestedId: string) { + requestControllers[resourceType]?.abort(); + clearResourceMessage(resourceType); + resources[resourceType].data = []; + + const trimmedId = requestedId.trim(); + + if (!trimmedId.length) { + setResourceError( + resourceType, + `No ID provided. Please input a ${resourceType} ID.` + ); + return; + } + + const controller = new AbortController(); + requestControllers[resourceType] = controller; + resources[resourceType].submittedId = trimmedId; + resources[resourceType].status = "loading"; try { - const res = await fetch(getApiUrl()); + const res = await fetch(getApiUrl(resourceType, trimmedId), { + signal: controller.signal, + }); const responseJson: unknown = await res.json(); + + if (controller.signal.aborted) { + return; + } + const errorResult = errorResponseSchema.safeParse(responseJson); if (errorResult.success) { - setError(errorResult.data.error.message); + setResourceError(resourceType, errorResult.data.error.message); return; } const successResult = successResponseSchema.safeParse(responseJson); if (!successResult.success) { - setError("The API returned an unexpected response format."); + setResourceError( + resourceType, + "The API returned an unexpected response format." + ); return; } - if (successResult.data.type !== type) { - setError("The API response type did not match the requested resource."); + if (successResult.data.type !== resourceType) { + setResourceError( + resourceType, + "The API response type did not match the requested resource." + ); return; } const parsedData = - params.type === "channel" + resourceType === "channel" ? channelDataSchema.safeParse(successResult.data.data) : videoDataSchema.safeParse(successResult.data.data); if (!parsedData.success) { - // console.log(parsedData.error); - setError("The API returned malformed snapshot data."); + setResourceError( + resourceType, + "The API returned malformed snapshot data." + ); return; } - data = parsedData.data; + const sortedData = sortSnapshots(parsedData.data); + + if (!sortedData.length) { + setResourceError(resourceType, "No snapshots were found for this ID."); + return; + } + + resources[resourceType].data = sortedData; + resources[resourceType].error = ""; + resources[resourceType].status = "idle"; } catch (err) { - setError( + if (controller.signal.aborted) { + return; + } + + setResourceError( + resourceType, err instanceof Error ? err.message : "Failed to load data from the API." ); - return; + } finally { + if (requestControllers[resourceType] === controller) { + delete requestControllers[resourceType]; + } } - - stats = getStatsForType(params.type); - - status = "idle"; } async function onSubmit(event: SubmitEvent) { event.preventDefault(); - await fetchData(); + + const resourceType = type; + const submittedId = resources[resourceType].draftId.trim(); + setExclusiveUrlId(resourceType, submittedId); + + await fetchData(resourceType, submittedId); } - const selectTriggerContent = $derived( - sources.find((source) => source.value === params.source)?.name - ); + onMount(() => { + const applyUrlState = () => { + const urlState = getUrlState(); + source = urlState.source; + type = urlState.type; + lastSource = urlState.source; + lastType = urlState.type; - let type = $derived(params.type); + resources.channel.draftId = urlState.channelId; + resources.channel.submittedId = urlState.channelId; + resources.video.draftId = urlState.videoId; + resources.video.submittedId = urlState.videoId; - $effect(() => { - type; - clearError(); - stats = []; - data = []; - }); + setExclusiveUrlId(urlState.type, resources[urlState.type].submittedId); + }; - onMount(() => { - if (params.id.length) { - void fetchData(); + applyUrlState(); + + const activeInitialId = resources[type].submittedId.trim(); + + if (activeInitialId.length) { + void fetchData(type, activeInitialId); } - }); - const capitalize = (str: string) => - str[0].toUpperCase() + str.slice(1).toLowerCase(); + window.addEventListener("popstate", applyUrlState); - const getStatValue = (snapshot: Snapshot, key: StatKey) => { - if (key in snapshot) { - return snapshot[key as keyof typeof snapshot] as number | null; + return () => { + window.removeEventListener("popstate", applyUrlState); + }; + }); + + $effect(() => { + if (source !== lastSource) { + lastSource = source; + resetAllLoadedData(); + syncUrlToActiveType(type); } + }); - return null; - }; + $effect(() => { + if (type !== lastType) { + lastType = type; + syncUrlToActiveType(type); + } + }); const escapeCsvValue = (value: string | number | null) => { if (value === null) { @@ -370,23 +538,24 @@ return previous?.value ?? next?.value ?? null; }; - const aggregateSnapshots = (period: ExportPeriod) => { + const aggregateSnapshots = (snapshots: Snapshot[], period: ExportPeriod) => { if (period === "all") { - return data.map((snapshot) => ({ ...snapshot })); + return sortSnapshots(snapshots); } const buckets = new Map(); - for (const snapshot of data) { + for (const snapshot of sortSnapshots(snapshots)) { const bucketDate = startOfPeriod(snapshot.date, period); - buckets.set(formatDateKey(bucketDate, period), { + const dateKey = formatDateKey(bucketDate, period); + buckets.set(dateKey, { ...snapshot, - date: formatDateKey(bucketDate, period), + date: dateKey, }); } return [...buckets.values()].sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + (a, b) => getSnapshotTime(a) - getSnapshotTime(b) ); }; @@ -430,7 +599,7 @@ for (let index = snapshots.length - 1; index >= 0; index -= 1) { const snapshot = snapshots[index]; const value = getStatValue(snapshot, stat.key); - const time = new Date(snapshot.date).getTime(); + const time = getSnapshotTime(snapshot); if (time <= targetTime && value !== null) { previous = { time, value }; @@ -440,7 +609,7 @@ for (const snapshot of snapshots) { const value = getStatValue(snapshot, stat.key); - const time = new Date(snapshot.date).getTime(); + const time = getSnapshotTime(snapshot); if (time >= targetTime && value !== null) { next = { time, value }; @@ -464,7 +633,7 @@ }; const buildExportSnapshots = (period: ExportPeriod) => { - const aggregated = aggregateSnapshots(period); + const aggregated = aggregateSnapshots(activeResource.data, period); return interpolateMissingColumns ? buildInterpolatedSnapshots(aggregated, period) @@ -473,6 +642,11 @@ const buildCsv = (period: ExportPeriod) => { const exportSnapshots = buildExportSnapshots(period); + + if (!exportSnapshots.length) { + throw new Error("There is no loaded data to export."); + } + const statNames = stats.map((stat) => stat.name); if (!flipRowsAndColumns) { @@ -503,12 +677,13 @@ const buildExportFilename = (period: ExportPeriod) => { const safeId = - params.id.trim().replaceAll(/[^a-zA-Z0-9-_]+/g, "-") || "data"; - return `${params.source}-${params.type}-${safeId}-${period}.csv`; + activeResource.submittedId.trim().replaceAll(/[^a-zA-Z0-9-_]+/g, "-") || + "data"; + return `${source}-${type}-${safeId}-${period}.csv`; }; const downloadCsv = (period: ExportPeriod) => { - clearError(); + clearResourceMessage(type); try { const csv = buildCsv(period); @@ -520,17 +695,21 @@ anchor.click(); URL.revokeObjectURL(url); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to download CSV."); + setResourceError( + type, + err instanceof Error ? err.message : "Failed to download CSV." + ); } }; const copyCsv = async (period: ExportPeriod) => { - clearError(); + clearResourceMessage(type); try { await navigator.clipboard.writeText(buildCsv(period)); } catch (err) { - setError( + setResourceError( + type, err instanceof Error ? err.message : "Failed to copy CSV to the clipboard." @@ -539,20 +718,37 @@ }; -
- - + + YouTube Data Exporter + + +
+
+

+ SCTools Data Exporter +

+
+ + + Channel Data Video Data -
-
-
- - - +
+ +
+ + + {selectTriggerContent} @@ -564,42 +760,61 @@
-
- - + +
+ +
-
- - {#if status !== "loading" && data.length > 0} + + {#if hasLoadedData} Export Data
- Flip rows/columns + Flip rows/columns
Interpolate missing data @@ -637,24 +852,44 @@ {/if}
- {#if error} -

{error}

+ + {#if activeResource.error} +

+ {activeResource.error} +

+ {:else if activeResource.status === "loading"} +

+ Loading {type} data... +

+ {:else if activeResource.data.length === 0} +

+ Enter a {type} ID to fetch charts and export CSVs. +

{/if} - {#if status !== "loading" && data.length > 0 && stats} - {#each stats as stat (stat.key)} - [ - new Date(d.date).getTime(), - getStatValue(d, stat.key), - ]), - }, - ]} - /> - {/each} + + {#if hasLoadedData} +
+ {#each stats as stat (stat.key)} + [ + getSnapshotTime(d), + getStatValue(d, stat.key), + ]), + }, + ]} + /> + {/each} +
{/if} -
+