hi! i have a sleep 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 );
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, and grab the timestamps using @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時 :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 you can view (warning! 3 megabytes). otherwise, take a look at this interactive view: