Skip to content

Instantly share code, notes, and snippets.

@Lucent
Last active October 31, 2025 15:37
Show Gist options
  • Save Lucent/505dc4c0a40a7b45c7a70e47d643417f to your computer and use it in GitHub Desktop.
Save Lucent/505dc4c0a40a7b45c7a70e47d643417f to your computer and use it in GitHub Desktop.
Fetches Substack draft save times bins them into configurable N-minute chunks, and prints one ASCII line per day showing active vs idle periods
// Substack versions → per-day ASCII timeline + window-independent total.
// Paste into console on a /publish/post/{id} page.
(async () => {
const CHUNK_MINUTES = 5; // bucket size for both display and totals
const START_HOUR = 8; // display window start (local)
const END_HOUR = 2; // display window end (next day, local)
const ACTIVE_CHAR = "━"; // writing
const INACTIVE_CHAR = "·"; // idle
const FOLLOW_PREVIOUS = true; // walk previousBucket chain
const m = location.pathname.match(/\/publish\/post\/(\d+)/);
if (!m) throw new Error("Could not find /publish/post/{id} in URL");
const draftId = m[1];
const mmdd = d => `${String(d.getMonth()+1).padStart(2,"0")}/${String(d.getDate()).padStart(2,"0")}`;
const ACTIVE_MINUTES_DISPLAY = END_HOUR > START_HOUR
? (END_HOUR - START_HOUR) * 60 // same-day window
: ((24 - START_HOUR) + END_HOUR) * 60;
const SLOTS_PER_DAY = Math.ceil(ACTIVE_MINUTES_DISPLAY / CHUNK_MINUTES);
function dayKeyAndOffset(t) {
const d = new Date(t);
const h = d.getHours();
const dayRow = new Date(d.getFullYear(), d.getMonth(), d.getDate() - (h < START_HOUR ? 1 : 0));
let offsetMin;
if (h >= START_HOUR) offsetMin = (h - START_HOUR) * 60 + d.getMinutes();
else offsetMin = (24 - START_HOUR) * 60 + h * 60 + d.getMinutes();
return { keyDate: dayRow, mmdd: mmdd(dayRow), offsetMin };
}
async function fetchBucket(bucket) {
const url = `/api/v1/drafts/${draftId}/versions` + (bucket ? `?bucket=${bucket}` : "");
const r = await fetch(url, { credentials: "include" });
return r.json();
}
const allVersions = [];
let bucket = undefined;
do {
const payload = await fetchBucket(bucket);
if (payload?.versions?.contents?.length) allVersions.push(...payload.versions.contents);
bucket = FOLLOW_PREVIOUS ? payload?.versions?.previousBucket : null;
} while (bucket);
// === Build minute and chunk sets (GLOBAL, all-day, window-agnostic) ===
const minuteEpochsAll = new Set(
allVersions.map(v => new Date(v.LastModified)).map(d => Math.floor(d.getTime() / 60000))
);
const chunkEpochsAll = new Set(
[...minuteEpochsAll].map(min => Math.floor(min / CHUNK_MINUTES)) // unique chunks across 24h
);
// === Build DISPLAY rows (windowed) ===
const dayRows = new Map(); // key -> {dateObj,label,slots[]}
for (const minEpoch of minuteEpochsAll) {
const d = new Date(minEpoch * 60000);
const h = d.getHours();
const inWindow = (h >= START_HOUR) || (h < END_HOUR);
if (!inWindow) continue; // only affects visuals, not totals
const { keyDate, mmdd: label, offsetMin } = dayKeyAndOffset(d);
const key = keyDate.getTime();
if (!dayRows.has(key)) {
dayRows.set(key, { dateObj: keyDate, label, slots: Array(SLOTS_PER_DAY).fill(INACTIVE_CHAR) });
}
const row = dayRows.get(key);
const slot = Math.floor(offsetMin / CHUNK_MINUTES);
if (slot >= 0 && slot < SLOTS_PER_DAY) row.slots[slot] = ACTIVE_CHAR;
}
const rowsSorted = [...dayRows.values()].sort((a,b) => a.dateObj - b.dateObj);
console.log(
`Draft ${draftId} — ${CHUNK_MINUTES}-min chunks, window ${START_HOUR}:00→${END_HOUR}:00 (display only)`
);
console.log(`${ACTIVE_CHAR}=writing, ${INACTIVE_CHAR}=idle`);
for (const r of rowsSorted) {
console.log(`${r.label} ${r.slots.join("")}`);
}
// === Window-independent total active time ===
const totalMinutes = chunkEpochsAll.size * CHUNK_MINUTES;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
console.log(`\nTotal active writing time (24h, window-agnostic, chunked): ${hours}h ${minutes}m`);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment