Last active
November 18, 2025 09:27
-
-
Save Lucent/505dc4c0a40a7b45c7a70e47d643417f to your computer and use it in GitHub Desktop.
Fetches Substack draft save times and 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 + multi-scale totals + | |
| // fractal dimension (D) + 5s-resolution "true" writing time estimate. | |
| // Paste into console on a /publish/post/{id} page. | |
| (async () => { | |
| const CHUNK_MINUTES = 5; // bucket size for DISPLAY + legacy total | |
| 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 | |
| // Bin sizes for fractal analysis (seconds) | |
| const FRACTAL_BIN_SECONDS = [30, 60, 120, 240, 480, 960, 1920]; | |
| // "Micro" resolution, based on observed Substack save cadence (2–10s typical) | |
| const MICRO_BIN_SECONDS = 5; | |
| const m = location.pathname.match(/\/publish\/post\/(\d+)/); | |
| 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 | |
| : ((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; | |
| 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); | |
| // === Raw timestamps (ms since epoch), one per version === | |
| const timestampsMs = allVersions | |
| .map(v => new Date(v.LastModified).getTime()) | |
| .sort((a, b) => a - b); | |
| // === Minute & chunk sets for DISPLAY + legacy total (GLOBAL, 24h) === | |
| const minuteEpochsAll = new Set( | |
| timestampsMs.map(t => Math.floor(t / 60000)) | |
| ); | |
| const chunkEpochsAll = new Set( | |
| [...minuteEpochsAll].map(min => Math.floor(min / CHUNK_MINUTES)) | |
| ); | |
| // === 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; | |
| const { keyDate, mmdd: label, offsetMin } = dayKeyAndOffset(d.getTime()); | |
| 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("")}`); | |
| } | |
| // === Legacy window-independent total active time at 5-min granularity === | |
| const legacyTotalMinutes = chunkEpochsAll.size * CHUNK_MINUTES; | |
| const legacyHours = Math.floor(legacyTotalMinutes / 60); | |
| const legacyMinutes = legacyTotalMinutes % 60; | |
| console.log( | |
| `\nTotal active writing time (24h, window-agnostic, ${CHUNK_MINUTES}-min chunks): ${legacyHours}h ${legacyMinutes}m` | |
| ); | |
| // === Multi-scale totals for FRACTAL_BIN_SECONDS (24h, window-agnostic) === | |
| function totalsForBinSeconds(binSec) { | |
| const binMs = binSec * 1000; | |
| const bins = new Set(); | |
| for (const t of timestampsMs) { | |
| bins.add(Math.floor(t / binMs)); | |
| } | |
| const binCount = bins.size; | |
| const totalMinutes = (binCount * binSec) / 60; | |
| const hours = Math.floor(totalMinutes / 60); | |
| const minutes = Math.round(totalMinutes % 60); | |
| return { binSec, binCount, totalMinutes, hours, minutes }; | |
| } | |
| const binResults = FRACTAL_BIN_SECONDS.map(totalsForBinSeconds); | |
| console.log(`\nTotal active writing times by bin (24h, window-agnostic):`); | |
| for (const r of binResults) { | |
| const label = r.binSec >= 60 ? `${r.binSec / 60}m` : `${r.binSec}s`; | |
| console.log(` ${label}: ${r.hours}h ${String(r.minutes).padStart(2, "0")}m`); | |
| } | |
| // === Fractal dimension D from N(b) ~ b^(−D) === | |
| const xs = binResults.map(r => Math.log(r.binSec)); // log b | |
| const ys = binResults.map(r => Math.log(r.binCount)); // log N(b) | |
| const n = xs.length; | |
| let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0; | |
| for (let i = 0; i < n; i++) { | |
| const x = xs[i]; | |
| const y = ys[i]; | |
| sumX += x; | |
| sumY += y; | |
| sumXX += x * x; | |
| sumXY += x * y; | |
| } | |
| const denom = n * sumXX - sumX * sumX; | |
| const slope = (n * sumXY - sumX * sumY) / denom; // slope of log N vs log b | |
| const D = -slope; // N(b) ∝ b^(−D) | |
| // === Fractal "true time" at MICRO_BIN_SECONDS resolution === | |
| // Using N(ε0) ≈ N(b)·(b/ε0)^D, T_true ≈ N(ε0)·ε0 = N(b)·b^D·ε0^(1−D) | |
| let sumTrueSec = 0; | |
| for (const r of binResults) { | |
| const B = r.binSec; | |
| const N_B = r.binCount; | |
| const trueSec = N_B * Math.pow(B, D) * Math.pow(MICRO_BIN_SECONDS, 1 - D); | |
| sumTrueSec += trueSec; | |
| } | |
| const avgTrueSec = sumTrueSec / binResults.length; | |
| const trueTotalMinutes = avgTrueSec / 60; | |
| const trueHours = Math.floor(trueTotalMinutes / 60); | |
| const trueMinutes = Math.round(trueTotalMinutes % 60); | |
| console.log(`\nFractal-ish start/stoppiness:`); | |
| console.log(` Dimension D ≈ ${D.toFixed(3)}`); | |
| console.log( | |
| ` Estimated "true" writing time at ~${MICRO_BIN_SECONDS}s resolution: ≈ ${trueHours}h ${String(trueMinutes).padStart(2, "0")}m` | |
| ); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment