可视化我的睡眠日程
摘要
一个使用来自Discord和Twitter的带时间戳在线活动来可视化睡眠障碍的个人项目,使用Deno和TypeScript实现。
<p><a href="https://lobste.rs/s/20c9v3/graphing_my_sleep_schedule">评论</a></p>
查看缓存全文
缓存时间: 2026/09/22 12:37
# 绘制我的睡眠时间表 - 夏洛特!
来源: https://char.lt/blog/2026/09/non24swd
嗨!我有一种睡眠障碍(https://en.wikipedia.org/wiki/Non-24-hour_sleep%E2%80%93wake_disorder)^\-^ 我当时想:“我应该把这个画出来”,但因为我感官上的原因无法忍受手表戴在手腕上的感觉,所以没有智能手表之类能直接绘制睡眠数据的设备。于是我转而利用能找到的尽可能多的带时间戳的在线活动数据,将其“负空间”作为睡眠数据来绘制。这效果相当不错,因为我也不怎么出门 :3
**深入我的超级自窥镜**
我们首先需要收集大量日期数据,然后绘制一张SVG图。对于这类一次性脚本,我喜欢用Deno,因为可以*直接*写TypeScript而完全不用设置项目,甚至能直接从jsr和npm引入依赖 :)
## 数据抓取
我们需要读取压缩文件,因此要在Deno中连接zip\.js:
```
import { ZipReader, Reader, TextWriter } from "npm:@zip.js/zip.js"
// 为zip.js适配Deno API
class DenoFileReader extends Reader {
constructor(private file: Deno.FsFile) {
super(file);
this.size = file.statSync().size; // 阻塞式I/O;无所谓 >:)
}
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数据是我目前最丰富的数据源:由于它是我与大多数人主要的沟通方式,我几乎全程都在线。申请数据包并等待几天后,我会得到一个结构如下的压缩文件:
```
Account/
Ads/
[...]
Messages/
# 许多频道ID:
c{channel_id}/
channel.json
messages.json
```
每个`messages.json`都是一个顶层JSON数组,包含元素`{ ID: string, Timestamp: string, Contents: string, Attachments: string }`。时间戳是不带时区的`YYYY-MM-DD HH:mm:ss`格式字符串,但通过与应用界面的时间交叉核对,我们可以安全地将其视为UTC时间。
```
type DiscordMessage = {
Timestamp: string;
};
async function extractDiscord(zip: ZipReader, 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
Twitter的GDPR导出有点奇怪。我们有一个`data/tweet-headers.js`文件,好像是JSONP格式?我猜我们只需截取字符串去掉前缀`window.YTD.tweet_headers.part0 =`即可。
```
import { expandGlob } from "jsr:@std/fs"
type TwitterTweet = {
tweet: {
created_at: string;
};
};
async function extractTwitter(zip: ZipReader, 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);
}
```
### AT Protocol (atproto)
我喜爱AT Protocol ^-^ 所有关心的记录都有一个TID rkey,这实际上是一个(可排序的)Base32编码的时间戳。我只需获取我的CAR文件,用`@atcute/repo`(https://npmx.dev/package/@atcute/repo)解析它们,然后使用`@atcute/tid`(https://npmx.dev/package/@atcute/tid)解析时间戳 - 感谢mary <3
`@atcute/repo`封装了CAR读取器,并为我们提供每条记录的rkey。我们将跳过非TID键(如`self`),并将解码后的时间戳除以1,000,000将微秒转换为秒。
```
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
我以前待过的大多数Mastodon实例现在都已失效,但我还有我的个人自托管Akkoma实例!我将直接从PostgreSQL数据库中提取我的活动时间戳 :)
```
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
```
并将此文件也导入到TypeScript中:
```
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);
}
```
## 渲染图表
我们将从提取脚本中导出`timestamps`,并编写另一个脚本来输出活动图(actogram)的SVG!我们将使用2021年1月作为截止点,因为在此之前的可靠数据较少(已删除的Discord服务器、不存在的Twitter账户等)。此外,由于大量数据跨越UTC午夜,我们将采用一点视觉冗余,时间轴延伸到30时(https://en.wikipedia.org/wiki/Date_and_time_notation_in_Japan#Times_past_midnight):D
我们不会为图表中的每个时间点单独生成一个``元素(这将创建数十万个SVG对象!),而是按显示内容进行批量处理——我们利用一个``元素可以包含多个不相连子路径的特性来保持元素数量低:我们为每种活动强度类型(包括睡眠间隔)创建一个``对象,然后使用许多断开的`M...z`矩形作为`d`属性。耶!
如果您没有启用JavaScript,可以查看这个[静态SVG](https://char.lt/blobs/actogram-202609.svg)(警告!3MB)。或者,请看看这个交互式视图:
或者绘制您自己的活动图(需要一个`number[]`格式的JSON输入):
相似文章
我让 AI 帮我构建了一个工具,用来找出夜里吵醒我的原因
作者详细介绍了如何使用 AI 编程助手构建一个定制的智能家居工具,该工具通过关联音频录音、传感器数据和睡眠指标来识别夜间干扰源。这个 DIY 项目将树莓派、麦克风、Home Assistant 和 Garmin 手表整合到一个个性化的网页仪表板中。
@polydao:循环运行的解剖结构——在你睡觉时,一位Anthropic工程师在合上的笔记本电脑上运行自主代码周期,使…
一位Anthropic工程师分享了一种自主代码循环架构,该架构在合上的笔记本电脑上运行,无需聊天提示即可发起拉取请求并运行测试。系统使用一个包含合同、日程、评分标准和状态组件的五文件文件夹,而Fable 5推理循环的订阅窗口将于7月12日关闭。
支持 CRDT 的 Type-Safe 实时协作图数据库
Codemix 开源了 @codemix/graph,这是一款具备 TypeScript 原生模式验证、基于 CRDT 的图数据库,并通过 Yjs 实现实时离线优先同步。
我让AI助手将6个月Apple Watch睡眠数据转换成睡眠门诊要求的日志。数据中的陷阱相当棘手。
一位用户详述了使用AI助手将6个月Apple Watch睡眠数据转换为睡眠门诊日志格式时遇到的挑战,包括时区转换、日期偏移和捏造的值。这篇文章分享了正确解读医疗表单健康数据来源的经验教训。
花了最近几周时间构建了一个替代繁重AI可观测性工具的产品,因为我厌倦了混乱的日志。需要来自Next.js/Node开发者的反馈。
一位开发者构建了 TracePilot,一个轻量级、零依赖的npm SDK,用于AI可观测性,以简化生产环境中的提示调试,提供实时延迟、令牌成本和错误追踪。