Avatar

@tmcw

36 likes153 public vals
Joined August 31, 2022
đź‘· Building Val Town

Get a DuckDB database

This method sets up a duckdb-wasm database that you can then use to store and retrieve data.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { fetch } from "https://esm.town/v/std/fetch";
export let getDuckDB = (async () => {
async function createWorker(url: string) {
const workerScript = await fetch(url);
const workerURL = URL.createObjectURL(await workerScript.blob());
return new Worker(workerURL, { type: "module" });
}
const duckdb = await import(
"https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.17.0/+esm"
);
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
const logger = new duckdb.ConsoleLogger();
const worker = await createWorker(bundle.mainWorker);
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule);
return db;
});

Val Town API: Find a val by handle and name

This val fetches itself via the Val Town API! Weird, right?

Readme
1
2
3
4
5
6
7
8
import { fetch } from "https://esm.town/v/std/fetch";
export let valTownApiExampleVal = (async () => {
const response = await fetch(
`https://api.val.town/v1/alias/tmcw/valTownApiExampleVal`,
);
return await response.json();
})();

Unfancy blocks

Screenshot

In the old days, there was a website called bl.ocks.org which re-hosted content on GitHub Gists and displayed visualizations made in those gists. It wasn't shiny but it was well-crafted and for the little community that used it, it was very nice.

This is a much worse, but very small, version of the same kind of program. This also shows you stuff from blocks. It displays readmes, with Titus's lovely micromark. It uses Ian's collected search index of bl.ocks.org to show you examples and thumbnails. It uses Alpine to pull together a little interface. And, of course, Val Town to make it all work.

Readme
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { fetch } from "https://esm.town/v/std/fetch";
export let blocks = async (req, res) => {
const { micromark } = await import("npm:micromark");
const [_, user, id, ...file] = req.path.split("/");
if (!user) {
return res.send(`
<html>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<body class='p-20 bg-stone-100'>
<div
x-data="{ page: 0, blocks: [],
get filteredBlocks() {
return this.blocks.filter(block => block.thumbnail)
.reverse()
.map(block => {
return {
...block,
href: '/' + block.userId + '/' + block.id
}
}).slice(this.page * 100, (this.page + 1) * 100)
}
}"
x-init="blocks = await (await fetch('https://storage.googleapis.com/bb-search-data/parsed/blocks-min.json')).json()"
>
<div class='grid gap-4 grid-cols-4 lg:grid-cols-8'>
<template x-for="block in filteredBlocks">
<a class="aspect-square p-4 block" x-bind:href="block.href">
<div>
<span x-text="block.description"></span>
by <span x-text="block.userId"></span>
</div>
<template x-if="block.thumbnail">
<img x-bind:src="block.thumbnail" class='w-full' />
</template>
</a>
</template>
</div>
<div class='flex items-center justify-center gap-x-2 pt-20'>
Page <div x-text="page"></div>
<template x-if="page > 0">
<button @click="page--">Previous page</button>
</template>
<button @click="page++">Next page</button>
</div>
</div>
</body>
</body>

TODO List

Every web project needs a todo list, right? This is val.town's first (maybe?) With a healthy dose of htmx and web fundamentals, we're packing the essentials of a TODO list with server persistence and animations into about 60 lines. The data is stored in the @tmcw.todos val, for now. Try it out.

Readme
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
let { todos } = await import("https://esm.town/v/tmcw/todos");
export let todo_list = (async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const renderTodo = (todo) => {
return html`<li data-id=${todo.id}>
<label>
<input hx-delete="/" hx-target="[data-id='${todo.id}']" hx-swap="delete" style='display:inline;margin-right:0.5rem;' type='checkbox' name="id" value=${todo.id} />
${todo.name}
</label>
</li>`;
};
switch (req.method) {
case "POST": {
const name = req.body.name.substring(0, 256).trim();
if (!name)
return res.end();
const todo = {
id: crypto.randomUUID(),
name,
};
todos = [...todos, todo];
if (todos.length > 10) {
todos = todos.slice(-10);
res.set("HX-Trigger", JSON.stringify({ reload: true }));
}
return res.send(renderTodo(todo));
}
case "DELETE": {
todos = todos.filter((todo) =>
todo.id !== req.body.id
);
return res.end();
}
}
if (req.path === "/list") {
return res.send(html`<>${todos.map(renderTodo)}</>`);
}
return res.send(html`<html><head><title>TODO</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://the.missing.style" />
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>
<body>
<main>
<h2>Todo list</h2>
<ul style='list-style-type:none;padding-left:0;' hx-trigger="reload from:body" hx-get="/list">

The Big Story

This val, along with @tmcw.big_story, which requests from the New York Times API, and @tmcw.big_stories_ranks, which contains the data, generates a visualization of top stories on the NYTimes homepage.

This is here just to ask the question – what happens to cover stories over time? Do they slowly drop down the page, or just get replaced by a fully new lede? So far it doesn't have quite enough data to answer that question.

But also, it might be neat because it'll show which kinds of stories make the front page - is it climate, war, politics, or something else?

👉 The Big Story (visualization)

Readme
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
export let big_story_visualization = async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const raw = (html) =>
vhtml(null, {
dangerouslySetInnerHTML: { __html: html },
});
res.send(html`
<html>
<body style="font-family: sans-serif;padding:30px;">
<h1>Big stories on the New York Times</h1>
<p>Below is a chart of headlines from the New York Times homepage, ordered
by their rank over time. Most stories start near the top and then get lower-ranked
when other news breaks. Some stories pop back up the ranks.
</p>
<p>Check out <a href='https://www.val.town/v/tmcw.big_story_visualization'>the code that makes this work</a> on <a href='https://val.town/'>val.town</a>.
</p>
<p>Hover over a line to see the story title, click on a line to view the story.</p>
<div style='padding-top:40px' id='chart'></div>
<script type="module">
${
raw(`
import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.9/+esm";
import {debounce} from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm";
const ranks = await (await fetch('https://api.val.town/v1/run/tmcw.big_stories_ranks')).json();
let allDates = new Set();
for (let rank of Object.values(ranks)) {
for (let [date] of rank.ranks) {
allDates.add(date);
}
}
allDates = [...allDates.values()].sort((a, b) => a - b);
const normalized = Object.entries(ranks).flatMap(([url, data]) => {
return allDates.map(date => {
return {
...data,
ranks: undefined,
url: url,
rank: data.ranks.find(rank => rank[0] === date)?.[1],
date: new Date(date),
};
});
});
function render() {
const width = window.innerWidth;
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/** @jsxImportSource npm:hono/jsx */
import { camelCase } from "https://deno.land/x/case@2.2.0/mod.ts";
import { slug } from "https://deno.land/x/slug@v1.1.0/mod.ts";
import { BaseUnit, Fmt } from "https://esm.town/v/tmcw/BaseUnit";
import { getCookie } from "https://esm.town/v/tmcw/getCookie";
import { getMaterialIds } from "https://esm.town/v/tmcw/getMaterialIds";
import { getMaterials } from "https://esm.town/v/tmcw/getMaterials";
import { glasses } from "https://esm.town/v/tmcw/glasses";
import { materials } from "https://esm.town/v/tmcw/materials";
import { materialType } from "https://esm.town/v/tmcw/materialType";
import { Recipe } from "https://esm.town/v/tmcw/Recipe";
import { recipes } from "https://esm.town/v/tmcw/recipes";
import { setCookie } from "https://esm.town/v/tmcw/setCookie";
import { sort } from "https://esm.town/v/tmcw/sort";
import { styleSystem } from "https://esm.town/v/tmcw/styleSystem";
import { Context, Hono } from "npm:hono@3.8.1";
import { createContext, Fragment, useContext } from "npm:hono@3.8.1/jsx";
import { Ingredient } from "https://esm.town/v/tmcw/Ingredient";
// TODO: Deno doesn't have a pattern for this?
const app = new Hono();
const { styleRoute, StyleTag } = styleSystem();
function MaterialsList() {
const c = useContext(RequestContext);
const mats = getMaterialIds(c);
return (
<plank id="materials-list" hx-swap-oob="true">
<details open>
<summary>Materials</summary>
<div>
Select{" "}
<button hx-post="/material" hx-swap="none" name="all" value="true">
All
</button>{" "}
<button hx-post="/material" hx-swap="none" name="all" value="false">
None
</button>
</div>
{Object.values(materialType).map((group) => {
return (
<material-group>
<material-group-name>{group.name}</material-group-name>
{group.links.map(
(mat) => {
// Possibly wait until loaded to toggle fully?

Elysia example

This is how you can use Elysia with Val Town.

Readme
Fork
1
2
3
4
5
6
export const elysiaExample = async (req) => {
const { Elysia } = await import("https://esm.sh/elysia@0.7.15");
const app = new Elysia()
.get("/", () => "Hello Elysia");
return app.fetch(req);
};

Quick poll

https://tmcw-poll.express.val.run

This val, along with a val called poll_results and a val called poll_options, lets you conduct a little poll using just Val Town! With the express API, this exposes a web interface in which people can click an option and vote. We make sure everyone only votes once by setting a cookie in their browser.

This uses htmx to make the voting experience slick, Tailwind to style the UI, and htm to generate HTML for the pages.

If you want to use this yourself, fork this val, the poll_results val, and the poll_options val, and customize the options list. You can delete the existing results as well to clear the data.

Readme
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { poll_results } from "https://esm.town/v/tmcw/poll_results";
import { poll_options } from "https://esm.town/v/tmcw/poll_options";
export let poll = async (req: express.Request, res: express.Response) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const cookies = await import("https://deno.land/std@0.193.0/http/cookie.ts");
const html = htm.bind(vhtml);
const head = html`<head>
<title>Poll</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>`;
const ChartPie =
html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4">
<path d="M12 9a1 1 0 01-1-1V3c0-.553.45-1.008.997-.93a7.004 7.004 0 015.933 5.933c.078.547-.378.997-.93.997h-5z" />
<path d="M8.003 4.07C8.55 3.992 9 4.447 9 5v5a1 1 0 001 1h5c.552 0 1.008.45.93.997A7.001 7.001 0 012 11a7.002 7.002 0 016.003-6.93z" />
</svg>
`;
const formClass = "p-2 group min-h-screen animate-all bg-pink-50";
function parseCookies(cookie: string) {
const out: Record<string, string> = {};
const c = cookie.split(";");
for (const kv of c) {
const [cookieKey, ...cookieVal] = kv.split("=");
const key = cookieKey.trim();
out[key] = cookieVal.join("=");
}
return out;
}
function getResults(hasVoted: boolean) {
return html`<html>
${head}
<body>
<form hx-post="/" class=${formClass}>
<h1 class="text-sm font-bold uppercase text-pink-900 pb-2 flex items-center gap-x-2">
${ChartPie}
poll results
</h1>
<div class='space-y-2'>
${
poll_options.map((option, i) => {
const count = poll_results.filter((result) => {
return result.value === option;
}).length;
const percentage = (count / poll_results.length) *
100;
return html`<div class='p-2 rounded-md block text-sm

NYC Charging Stations Data Analysis

CleanShot 2023-09-21 at 12.07.43@2x.png

https://tmcw-nychargingstations.web.val.run/

This analyzes some open data about electric car charging stations in New York State and bakes a website from it. Data is messy!

Readme
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { fetch } from "https://esm.town/v/std/fetch";
export const nyChargingStations = (async () => {
const { csvParse } = await import("npm:d3-dsv");
const { group } = await import("npm:d3-array");
const { micromark } = await import("npm:micromark");
const rows = await fetch(
"https://data.ny.gov/api/views/7rrd-248n/rows.csv?accessType=DOWNLOAD&sorting=true",
)
.then((r) => r.text())
.then(csvParse);
const facets = ["Groups With Access Code", "Access Days Time", "ZIP"];
let output = `# New York City Charging Station Analytics
From: [NYC Open Data](https://data.ny.gov/Energy-Environment/Electric-Vehicle-Charging-Stations-in-New-York/7rrd-248n) \n\n`;
for (let facet of facets) {
const groups = group(rows, (r) => r[facet]);
output += `# ${facet}\n` +
Array.from(groups.entries(), ([k, v]) => [k, v.length]).sort((
[a, b],
[a1, b1],
) => b1 - b)
.map(([a, b]) => `- ${a}: ${b}`)
.join("\n") +
"\n\n";
}
return new Response(
`<link rel="stylesheet" href="https://unpkg.com/missing.css@1.1.1"><main>` +
micromark(output),
{
headers: {
"Content-Type": "text/html",
},
},
);
});

This tracks the data produced by my iPhone trade-in values val.

Readme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// set by tmcw.trackIphoneTradein at 2023-10-14T05:08:46.031Z
export let iphoneTradeInValues = [{
"date": "2023-09-13T18:38:18.512Z",
"rows": [{
"name": "iPhone 14 Pro Max",
"val": "Up to $650"
}, {
"name": "iPhone 14 Pro",
"val": "Up to $570"
}, {
"name": "iPhone 14 Plus",
"val": "Up to $470"
}, {
"name": "iPhone 14",
"val": "Up to $430"
}, {
"name": "iPhone SE (3rd generation)",
"val": "Up to $160"
}, {
"name": "iPhone 13 Pro Max",
"val": "Up to $580"
}, {
"name": "iPhone 13 Pro",
"val": "Up to $480"
}, {
"name": "iPhone 13",
"val": "Up to $370"
}, {
"name": "iPhone 13 mini",
"val": "Up to $320"
}, {
"name": "iPhone 12 Pro Max",
"val": "Up to $450"
}, {
"name": "iPhone 12 Pro",
"val": "Up to $360"
}, {
"name": "iPhone 12",
"val": "Up to $250"
}, {
"name": "iPhone 12 mini",
"val": "Up to $200"
}, {
"name": "iPhone SE (2nd generation)",
"val": "Up to $80"
}, {
"name": "iPhone 11 Pro Max",
"val": "Up to $300"
}, {
"name": "iPhone 11 Pro",