Last active
October 31, 2025 15:37
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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