graphing my sleep schedule

Lobsters Hottest News

Summary

A personal project to graph a sleep disorder using timestamped online activity from Discord and Twitter, implemented with Deno and TypeScript.

<p><a href="https://lobste.rs/s/20c9v3/graphing_my_sleep_schedule">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 09/22/26, 12:37 PM

# graphing my sleep schedule - charlotte! Source: [https://char.lt/blog/2026/09/non24swd](https://char.lt/blog/2026/09/non24swd) hi\! i have[a sleep disorder](https://en.wikipedia.org/wiki/Non-24-hour_sleep%E2%80%93wake_disorder)^\-^ i was like, “i should graph this thing,” but i don’t have a smart watch or anything \(i can’t handle the feeling of a watch on my wrist, because of, like, sensory reasons\) to directly graph my sleep, so instead i’ll take the negative space of as much timestamped online activity as i can find\. this works out pretty well, because i don’t go outside much :3 step into my superautopticon at a high level we want to collect a bunch of dates, and then draw an SVG\. for throwaway scripts like this i like to use Deno, because i get to*just*write typescript without setting up a project at all, and even pull in dependencies from jsr and npm :\) ## grabbing data we’re gonna need to read zipfiles, so we hook up zip\.js to deno: ``` import { ZipReader, Reader, TextWriter } from "npm:@zip.js/zip.js" // adapt deno apis for zip.js class DenoFileReader extends Reader<Deno.FsFile> { constructor(private file: Deno.FsFile) { super(file); this.size = file.statSync().size; // blocking i/o; don't care >:) } override async readUint8Array(offset: number, length: number): Promise<Uint8Array> { this.file.seekSync(offset, Deno.SeekMode.Start); const bytes = new Uint8Array(length); let read = 0; while (read < length) { const n = await this.file.read(bytes.subarray(read)); if (n === null) throw new Error("unexpected EOF"); read += n; } return bytes; } } ``` ### discord discord data is the richest source i have for this stuff: since it’s my primary mode of communication with most people, i’m on there, like, all the time\. after requesting my data package and waiting around for a couple days, i get a zip file that looks like: ``` Account/ Ads/ […] Messages/ # many channel ids: c{channel id}/ channel.json messages.json ``` and in each`messages\.json`is a top level json array with`\{ ID: string, Timestamp: string, Contents: string, Attachments: string \}`\- the timestamp is a timezoneless`YYYY\-MM\-DD HH:mm:ss`string, but after cross\-referencing with views on the app we can safely interpret it as UTC\. ``` type DiscordMessage = { Timestamp: string }; async function extractDiscord(zip: ZipReader<Deno.FsFile>, timestamps: number[]) { for (const entry of await zip.getEntries()) { if (entry.directory) continue; if (!entry.filename.endsWith("/messages.json")) continue; const text = await entry.getData(new TextWriter()); const messages: DiscordMessage[] = JSON.parse(text); for (const message of messages) { const isoTimestamp = message.Timestamp.replace(" ", "T") + "Z"; timestamps.push(Date.parse(isoTimestamp)); } } } const timestamps: number[] = []; using discordZip = await Deno.open("./discord-2026-09-19.zip"); await extractDiscord(new ZipReader(new DenoFileReader(discordZip)), timestamps); ``` ### twitter for twitter the GDPR export is kinda weird\. we have a`data/tweet\-headers\.js`which is, like, JSONP? we’ll just slice the string to get rid of the prefixed`window\.YTD\.tweet\_headers\.part0 =`, i guess\. ``` import { expandGlob } from "jsr:@std/fs" type TwitterTweet = { tweet: { created_at: string } }; async function extractTwitter(zip: ZipReader<Deno.FsFile>, timestamps: number[]) { for (const entry of await zip.getEntries()) { if (entry.directory) continue; if (!entry.filename.endsWith("/tweet-headers.js")) continue; const text = await entry.getData(new TextWriter()); const tweets: TwitterTweet[] = JSON.parse(text.slice(text.indexOf("=") + 1)); for (const { tweet } of tweets) { timestamps.push(Date.parse(tweet.created_at)); } } } for await (const archive of expandGlob("twitter-*.zip")) { using file = await Deno.open(archive.path); await extractTwitter(new ZipReader(new DenoFileReader(file)), timestamps); } ``` ### atproto i love atproto ^\-^ all the records I care about have a TID rkey, which is literally a \(sortable\)base32\-encoded timestamp\. i’ll just grab my CAR files, parse them with[`@atcute/repo`](https://npmx.dev/package/@atcute/repo), and grab the timestamps using[`@atcute/tid`](https://npmx.dev/package/@atcute/tid)to parse the timestamps \- thanks mary <3 `@atcute/repo`wraps the CAR reader and gives us each record’s rkey\. we’ll skip non\-TID keys \(like`self`\), and divide the decoded timestamps by 1,000,000 to turn microseconds into seconds\. ``` import { fromUint8Array } from "npm:@atcute/repo" import * as TID from "npm:@atcute/tid" for await (const archive of expandGlob("*.car")) { const repo = fromUint8Array(await Deno.readFile(archive.path)); for (const { rkey } of repo) { if (!TID.validate(rkey)) continue; timestamps.push(TID.parse(rkey).timestamp / 1000); } } ``` ### activitypub most of the previous mastodon instances i’ve been on are now dead, but i still have my personal self\-hosted akkoma instance\! i’ll pull my activity timestamps directly out of the postgres database :\) ``` sudo -u pleroma psql -XAt > akkoma-timestamps.txt <<'SQL' SELECT extract(epoch FROM a.inserted_at AT TIME ZONE 'UTC') FROM activities a JOIN users u ON u.ap_id = a.actor WHERE a.local AND u.local AND u.nickname IN ('bun', 'charlotte') AND a.data->>'type' IN ('Create', 'Like') ORDER BY a.inserted_at; SQL ``` and ingest this file into typescript also: ``` const akkomaText = await Deno.readTextFile("./akkoma-timestamps.txt"); for (const line of akkomaText.split(/\r?\n/)) { if (!line.trim()) continue; timestamps.push(Number(line) * 1000); } ``` ## rendering the graph we’ll export`timestamps`from the extraction script, and write another to spit out an actogram SVG\! we’ll use January 2021 as the cutoff, since we don’t have reliable data before then \(deleted Discord servers, non\-existence of twitter accounts, etcetc\) and since so much data cuts across UTC midnight we’ll deploy a little visual redundancy and[go until 30時](https://en.wikipedia.org/wiki/Date_and_time_notation_in_Japan#Times_past_midnight):D rather than using a`<rect\>`per time\-bin in the graph \(which would create hundreds of thousands of SVG objects\!\) we batch everything by presentation \- we exploit the fact that a`<path\>`can contain many disjoint subpaths to keep the element count low: we have a`<path\>`object per activity intensity type \(including sleep gaps\) and then use a bunch of disconnected`M…z`rectangles as the`d`attribute\. yaaaay if you don’t have JavaScript enabled, i have a[static SVG](https://char.lt/blobs/actogram-202609.svg)you can view \(warning\! 3 megabytes\)\. otherwise, take a look at this interactive view: or graph your own activity\(expects a`number\[\]`as JSON\):

Similar Articles

I let AI build a tool to help me figure out what was waking me up at night

Hacker News Top

The author details using AI coding assistants to build a custom smart-home tool that correlates audio recordings, sensor data, and sleep metrics to identify nighttime disturbances. The DIY project integrates a Raspberry Pi, microphones, Home Assistant, and a Garmin watch into a personalized web dashboard.