graphing my sleep schedule

computer art! non-24-hour sleep/wake exhibit

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);

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, 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: