Skip to content

Commit 8a98067

Browse files
New sub-workspace for client-side apps.
1 parent 4303971 commit 8a98067

File tree

11 files changed

+8555
-47
lines changed

11 files changed

+8555
-47
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
"files.exclude": {
55
"deno-*": true,
66
"deno.lock": true,
7+
"app": true,
78
}
89
}

app/build.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { transform, stop } from 'https://deno.land/x/[email protected]/mod.js';
2+
import { readAll } from 'https://deno.land/[email protected]/streams/read_all.ts';
3+
4+
async function fileExists(filePath: string | URL): Promise<boolean> {
5+
try {
6+
await Deno.lstat(filePath);
7+
return true;
8+
} catch (error) {
9+
if (error instanceof Deno.errors.NotFound) {
10+
return false;
11+
}
12+
throw error;
13+
}
14+
}
15+
16+
const text = new TextDecoder().decode(await readAll(Deno.stdin));
17+
const [ output ] = Deno.args;
18+
19+
if (!fileExists(output)) throw new Error(`Output file must exist`);
20+
21+
const { code } = await transform(text, { treeShaking: true, target: 'es2020' });
22+
const outputJs = `// deno-lint-ignore-file\n${code}`;
23+
await Deno.writeTextFile(output, outputJs);
24+
25+
stop();

app/deno.jsonc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"lib": [
4+
"dom",
5+
"dom.iterable",
6+
"dom.asynciterable",
7+
"deno.ns"
8+
]
9+
},
10+
"tasks": {
11+
"build": "deno bundle show.ts | deno run -A build.ts ../worker/static/show.js",
12+
"build-watch": "deno bundle show.ts ../worker/static/show.js --watch",
13+
}
14+
}

app/deps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Chart } from 'https://esm.sh/stable/[email protected]/auto';

app/op3-app.code-workspace

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"folders": [
3+
{
4+
"path": "."
5+
}
6+
],
7+
"settings": {
8+
"deno.enable": true,
9+
"deno.unstable": true,
10+
}
11+
}

app/show.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { ApiShowsResponse, ApiShowStatsResponse } from '../worker/routes/api_shows_model.ts';
2+
import { Chart } from './deps.ts';
3+
4+
// provided server-side
5+
declare const initialData: { showObj: ApiShowsResponse, statsObj: ApiShowStatsResponse, times: Record<string, number> };
6+
declare const previewToken: string;
7+
8+
function drawDownloadsChart(id: string, hourlyDownloads: Record<string, number>, hourMarkers?: Record<string, unknown>,) {
9+
const maxDownloads = Math.max(...Object.values(hourlyDownloads));
10+
const markerData = Object.keys(hourlyDownloads).map(v => {
11+
if (hourMarkers && hourMarkers[v]) return maxDownloads;
12+
return undefined;
13+
});
14+
15+
const ctx = (document.getElementById(id) as HTMLCanvasElement).getContext('2d')!;
16+
17+
const labels = Object.keys(hourlyDownloads);
18+
const data = {
19+
labels: labels,
20+
datasets: [
21+
{
22+
data: Object.values(hourlyDownloads),
23+
fill: false,
24+
borderColor: 'rgb(75, 192, 192)',
25+
pointRadius: 0,
26+
borderWidth: 1,
27+
},
28+
{
29+
type: 'bar',
30+
data: markerData,
31+
borderColor: 'rgb(255, 99, 132)',
32+
backgroundColor: 'rgba(255, 99, 132, 0.2)'
33+
},
34+
]
35+
};
36+
37+
const config = {
38+
type: 'line',
39+
data: data,
40+
options: {
41+
animation: {
42+
duration: 100,
43+
},
44+
interaction: {
45+
intersect: false,
46+
},
47+
plugins: {
48+
legend: {
49+
display: false,
50+
}
51+
}
52+
}
53+
};
54+
55+
// deno-lint-ignore no-explicit-any
56+
new Chart(ctx, config as any);
57+
}
58+
59+
async function download(e: Event, showUuid: string, month: string) {
60+
e.preventDefault();
61+
62+
const parts = [];
63+
let continuationToken;
64+
const qp = new URLSearchParams(document.location.search);
65+
while (true) {
66+
const u = new URL(`/api/1/downloads/show/${showUuid}`, document.location.href);
67+
if (qp.has('ro')) u.searchParams.set('ro', 'true');
68+
const limit = qp.get('limit') ?? '20000';
69+
u.searchParams.set('start', month);
70+
u.searchParams.set('limit', limit);
71+
u.searchParams.set('token', previewToken);
72+
if (continuationToken) {
73+
u.searchParams.set('continuationToken', continuationToken);
74+
u.searchParams.set('skip', 'headers');
75+
}
76+
console.log(`fetch limit=${limit} continuationToken=${continuationToken}`);
77+
const res = await fetch(u.toString());
78+
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status} ${await res.text()}`);
79+
const blob = await res.blob();
80+
parts.push(blob);
81+
continuationToken = res.headers.get('x-continuation-token');
82+
if (typeof continuationToken !== 'string') break;
83+
}
84+
const { type } = parts[0];
85+
const blob = new Blob(parts, { type });
86+
87+
const blobUrl = URL.createObjectURL(blob);
88+
89+
const anchor = document.createElement('a');
90+
anchor.href = blobUrl;
91+
anchor.target = '_blank';
92+
anchor.download = `downloads-${month}.tsv`;
93+
anchor.click();
94+
95+
URL.revokeObjectURL(blobUrl);
96+
}
97+
98+
const app = (() => {
99+
100+
const [ debugDiv, downloadLinkAnchor ] =
101+
[ 'debug', 'download-link' ].map(v => document.getElementById(v)!);
102+
103+
const { showObj, statsObj, times } = initialData;
104+
const { showUuid } = showObj;
105+
if (typeof showUuid !== 'string') throw new Error(`Bad showUuid: ${JSON.stringify(showUuid)}`);
106+
107+
const hourMarkers = Object.fromEntries(Object.entries(statsObj.episodeFirstHours).map(([episodeId, hour]) => [hour, episodeId]));
108+
drawDownloadsChart('show-downloads', statsObj.hourlyDownloads, hourMarkers);
109+
let n = 1;
110+
for (const episode of showObj.episodes) {
111+
const episodeHourlyDownloads = statsObj.episodeHourlyDownloads[episode.id];
112+
if (!episodeHourlyDownloads) continue;
113+
drawDownloadsChart(`episode-${n}-downloads`, episodeHourlyDownloads);
114+
n++;
115+
if (n > 4) break;
116+
}
117+
118+
downloadLinkAnchor.onclick = async e => await download(e, showUuid, '2022-12');
119+
120+
function update() {
121+
debugDiv.textContent = Object.entries(times).map(v => v.join(': ')).join('\n')
122+
console.log(initialData);
123+
}
124+
125+
return { update };
126+
})();
127+
128+
globalThis.addEventListener('DOMContentLoaded', () => {
129+
console.log('Document content loaded');
130+
app.update();
131+
});

worker/responses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function newJsonResponse(obj: Record<string, unknown>, status = 200): Response {
1+
export function newJsonResponse(obj: unknown, status = 200): Response {
22
return new Response(JSON.stringify(obj, undefined, 2), { status, headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' } });
33
}
44

worker/routes/api_shows.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { newJsonResponse, newMethodNotAllowedResponse } from '../responses.ts';
99
import { RpcClient } from '../rpc_model.ts';
1010
import { addMonthsToMonthString } from '../timestamp.ts';
1111
import { isValidUuid } from '../uuid.ts';
12+
import { ApiShowsResponse, ApiShowStatsResponse } from './api_shows_model.ts';
1213

1314
export async function computeShowsResponse({ showUuid, method, searchParams, rpcClient, roRpcClient, times = {} }: { showUuid: string, method: string, searchParams: URLSearchParams, rpcClient: RpcClient, roRpcClient?: RpcClient, times?: Record<string, number> }): Promise<Response> {
1415
if (method !== 'GET') return newMethodNotAllowedResponse(method);
@@ -33,7 +34,7 @@ export async function computeShowsResponse({ showUuid, method, searchParams, rpc
3334
.sort(compareByDescending(r => r.pubdateInstant))
3435
.map(({ id, title, pubdateInstant }) => ({ id, title, pubdate: pubdateInstant }));
3536

36-
return newJsonResponse({ showUuid, title, episodes });
37+
return newJsonResponse({ showUuid, title, episodes } as ApiShowsResponse);
3738
}
3839

3940
export async function computeShowStatsResponse({ showUuid, method, searchParams, statsBlobs, roStatsBlobs, times = {} }: { showUuid: string, method: string, searchParams: URLSearchParams, statsBlobs?: Blobs, roStatsBlobs?: Blobs, times?: Record<string, number> }): Promise<Response> {
@@ -66,5 +67,5 @@ export async function computeShowStatsResponse({ showUuid, method, searchParams,
6667
}
6768
}
6869

69-
return newJsonResponse({ showUuid, episodeFirstHours, hourlyDownloads, episodeHourlyDownloads });
70+
return newJsonResponse({ showUuid, episodeFirstHours, hourlyDownloads, episodeHourlyDownloads } as ApiShowStatsResponse);
7071
}

worker/routes/api_shows_model.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface ApiShowsResponse {
2+
readonly showUuid: string;
3+
readonly title?: string;
4+
readonly episodes: readonly { readonly id: string, readonly title: string, readonly pubdate: string }[];
5+
}
6+
7+
export interface ApiShowStatsResponse {
8+
readonly showUuid: string;
9+
readonly episodeFirstHours: Record<string, string>;
10+
readonly hourlyDownloads: Record<string, number>;
11+
readonly episodeHourlyDownloads: Record<string, Record<string, number>>;
12+
}

worker/static/show.htm

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
<meta name="robots" content="noindex" />
1111
${styleTag}
1212
${shoelaceCommon}
13-
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.1.1/chart.umd.js" integrity="sha512-+Aecf3QQcWkkA8IUdym4PDvIP/ikcKdp4NCDF8PM6qr9FtqwIFCS3JAcm2+GmPMZvnlsrGv1qavSnxL8v+o86w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
1413
<script type="module">
1514
const initialData = /*${initialData}*/{};
1615
const previewToken = '${previewToken}';

0 commit comments

Comments
 (0)