From 6850b3cb4ff61cef73d996d0d6df88d78b36f5f0 Mon Sep 17 00:00:00 2001 From: Guy Sandler Date: Sun, 11 Oct 2026 16:52:59 -0700 Subject: [PATCH] custom timeframe FOR FREE --- css/popup.css | 10 ++- html/popup.html | 13 ++++ js/background.js | 3 + js/content.js | 175 +++++++++++++++++++++++++---------------------- js/popup.js | 44 +++++++++++- 5 files changed, 158 insertions(+), 87 deletions(-) diff --git a/css/popup.css b/css/popup.css index 220b28e..a6cdf2b 100644 --- a/css/popup.css +++ b/css/popup.css @@ -76,16 +76,22 @@ h2 {margin-top: 20px;font-weight:600} .custom-card-hide {display: flex; align-items: center;} .option-name {font-weight: 600} .sub-text {font-size: 12px;color: #c7c7c7} -#todo_progress_rings, #anon_grade_preset {font-size: 12px;background: var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;color-scheme:dark;} +#todo_progress_rings, #anon_grade_preset {font-size: 12px;background: var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;} #todo_progress_rings option, #anon_grade_preset option {background:#1e1e1e;color:#e2e2e2;} #todo_progress_rings option:checked, #todo_progress_rings option:hover, #anon_grade_preset option:checked, #anon_grade_preset option:hover {background:#2e2e2e;color:#fff;} -#todo_timeframe {font-size: 12px;background: var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;color-scheme:dark;} +#todo_timeframe {font-size: 12px;background: var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;} #todo_timeframe option {background:#1e1e1e;color:#e2e2e2;} #todo_timeframe option:checked, #todo_timeframe option:hover {background:#2e2e2e;color:#fff;} +/* Custom timeframe inputs, shown under the Time frame dropdown when "Custom" is selected */ +.custom-tf-wrap {display:none;flex-direction:column;gap:6px;} +.custom-tf-wrap.visible {display:flex;} +.custom-tf-row {display:flex;align-items:center;gap:6px;} +.custom-tf-row input {flex:none;width:58px;font-size:12px;background:var(--inputbg);border:none;padding:6px;color:#e2e2e2;border-radius:6px;font-family:inherit;text-align:right;box-sizing:border-box;color-scheme:dark;} +.custom-tf-row input::-webkit-outer-spin-button, .custom-tf-row input::-webkit-inner-spin-button {opacity:1;} /* Collapsible "More options" section for the Better Todo List sub-options */ .todo-more-options {margin-top: 7px;} .todo-more-toggle {display: flex;align-items:center;gap:5px;font-size:12px;color:#a7a7a7;cursor:pointer;user-select:none;padding:3px 0;border-radius:5px;transition:color .1s;} diff --git a/html/popup.html b/html/popup.html index f3af324..518024b 100644 --- a/html/popup.html +++ b/html/popup.html @@ -291,7 +291,20 @@ + +
+
+ From + + days ago +
+
+ To + + days from now +
+
diff --git a/js/background.js b/js/background.js index 7cc4865..09c8a94 100644 --- a/js/background.js +++ b/js/background.js @@ -88,6 +88,9 @@ chrome.runtime.onInstalled.addListener(function () { "todo_hide_feedback": false, "todo_full_height": true, "todo_progress_rings": "rings", + "todo_timeframe": "all", + "todo_timeframe_custom_past": 7, + "todo_timeframe_custom_future": 14, "todo_confetti": true, "todo_show_scores": false, "custom_font_skip_p": false, diff --git a/js/content.js b/js/content.js index 3c0380a..5ff3bea 100644 --- a/js/content.js +++ b/js/content.js @@ -1080,6 +1080,8 @@ function applyOptionsChanges(changes) { case "num_todo_items": case "hover_preview": case "todo_timeframe": + case "todo_timeframe_custom_past": + case "todo_timeframe_custom_future": // case "todo_overdues": case "todo_hide_feedback": case "todo_hide_read": @@ -2236,9 +2238,10 @@ function updateIndicator(element) { // better todo html betterTodoFilter = "tasks"; // Timeframe filter for the upcoming Tasks tab. "all" = no limit; otherwise -// items are limited to those due on/before now+range (which also keeps -// overdue items). Only affects the Tasks (upcoming) tab; announcements and -// completed are unaffected because their dates are in the past. +// items are limited to a window around now: one week of history back through +// now+range (older overdue backlog is cut). Only affects the Tasks (upcoming) +// tab; announcements and completed are unaffected because their dates are in +// the past. let betterTodoTimeframe = "all"; const BETTER_TODO_TIMEFRAME_DAYS = { "1week": 7, @@ -2246,6 +2249,10 @@ const BETTER_TODO_TIMEFRAME_DAYS = { "month": 30, "2month": 60, }; +// How much history the preset timeframes include: the current window starts +// this many days in the past so recently overdue work stays visible while +// older backlog is cut. Custom timeframes pick their own "days ago" bound. +const TODO_TIMEFRAME_LOOKBACK_DAYS = 4; // Announcements older than this are never shown in the Better Todo list (and // never count toward the unread badge). Unlike assignments, an announcement // stops being an actionable to-do once it's old: the planner cache keeps a @@ -2255,10 +2262,11 @@ const BETTER_TODO_TIMEFRAME_DAYS = { // available on the course's own Announcements page. const TODO_ANNOUNCEMENT_MAX_AGE_DAYS = 30; // Look-ahead paging for the timeframe window (arrow buttons under the Tasks -// header). 0 = the current window (now through the timeframe cutoff, with -// overdue items kept). Each press of the right arrow advances one full -// window further out, e.g. a 1-week timeframe on offset 1 shows only items -// due in week 2 (the current week is skipped over). +// header). 0 = the current window (from the lookback bound — 1 week back for +// presets, "X days ago" for custom — through the future edge). Each press of +// the right arrow advances one full window further out, e.g. a 1-week +// timeframe on offset 1 shows only items due in week 2 (the current window is +// skipped over). let betterTodoTimeframeOffset = 0; // null = show every class; a string courseId = only that class's tasks. let betterTodoProgressFilter = null; @@ -2267,25 +2275,73 @@ let domContainers = {}; // Resolve the persisted todo_timeframe option to a valid key ("all" when // unset/unknown). function getTodoTimeframeKey() { - return (options.todo_timeframe && Object.prototype.hasOwnProperty.call(BETTER_TODO_TIMEFRAME_DAYS, options.todo_timeframe)) ? options.todo_timeframe : "all"; + const allowed = ["all", "1week", "2week", "month", "2month", "custom"]; + return allowed.includes(options.todo_timeframe) ? options.todo_timeframe : "all"; +} + +// Custom timeframe bounds ("custom" option): the window runs from X days ago +// to Y days in the future. Clamped to 0-365 days; the defaults match the +// popup's initial inputs (1 week of history, 2 weeks ahead). +function getTodoCustomTimeframeDays() { + const past = parseInt(options.todo_timeframe_custom_past); + const future = parseInt(options.todo_timeframe_custom_future); + return { + past: isNaN(past) ? 7 : Math.max(0, Math.min(365, past)), + future: isNaN(future) ? 14 : Math.max(0, Math.min(365, future)), + }; +} + +// Total length of the current timeframe window in days ("all" = 0). Presets +// cover their range ahead plus the shared 1-week lookback; custom windows are +// as long as their past+future range. Either way the pager tiles windows back +// to back. +function getTodoTimeframeSpanDays() { + const tf = getTodoTimeframeKey(); + if (tf === "all") return 0; + if (tf === "custom") { + const c = getTodoCustomTimeframeDays(); + return Math.max(1, c.past + c.future); + } + return BETTER_TODO_TIMEFRAME_DAYS[tf] + TODO_TIMEFRAME_LOOKBACK_DAYS; +} + +// Days before "now" where the current window starts. Presets start at the +// shared 1-week lookback (recently overdue work is kept, older backlog is +// cut); custom windows start `past` days in the past per the user's setting. +function getTodoTimeframePastDays() { + const tf = getTodoTimeframeKey(); + if (tf === "all") return 0; + if (tf === "custom") return getTodoCustomTimeframeDays().past; + return TODO_TIMEFRAME_LOOKBACK_DAYS; } // Better Todo timeframe filter, shared by the task list and the progress // display so their counts always agree: keeps items due inside the current -// window. Offset 0 keeps everything on/before now+range (overdue items are -// before now, so they are kept too); offset N>0 shows only the window -// (now+N*range, now+(N+1)*range] so the current timeframe can be paged +// window. Offset 0 runs from the lookback bound (1 week back for presets, +// "X days ago" for custom) through the future edge, so recently overdue work +// stays visible while older backlog is cut; offset N>0 shows only the strict +// window (now+N*span, now+(N+1)*span] so the current timeframe can be paged // through with the look-ahead arrows. "all" keeps everything. -function applyTodoTimeframe(items) { +// `lenientPast` (Announcements / Completed tabs): on the current window those +// tabs keep everything on/before the cutoff — completed history and unread +// announcements older than the lookback are still meaningful there — while +// paging forward uses the same strict windows as Tasks. +function applyTodoTimeframe(items, lenientPast) { betterTodoTimeframe = getTodoTimeframeKey(); if (betterTodoTimeframe === "all") return items; - const days = BETTER_TODO_TIMEFRAME_DAYS[betterTodoTimeframe]; + const dayMs = 24 * 60 * 60 * 1000; const now = Date.now(); - const cutoff = now + ((betterTodoTimeframeOffset + 1) * days * 24 * 60 * 60 * 1000); - if (betterTodoTimeframeOffset <= 0) { + const days = getTodoTimeframeSpanDays(); + const pastDays = getTodoTimeframePastDays(); + // End of the current window: the future edge (presets end `days` out, + // custom windows end `future` days out; span - past = future in both). + const cutoff = now + ((betterTodoTimeframeOffset + 1) * days - pastDays) * dayMs; + // Start of the current window: the lookback bound (skipped for the + // lenient tabs on the current window). + if (lenientPast === true && betterTodoTimeframeOffset <= 0) { return items.filter(item => new Date(item.plannable_date).getTime() <= cutoff); } - const windowStart = now + (betterTodoTimeframeOffset * days * 24 * 60 * 60 * 1000); + const windowStart = now + (betterTodoTimeframeOffset * days - pastDays) * dayMs; return items.filter(item => { const t = new Date(item.plannable_date).getTime(); return t > windowStart && t <= cutoff; @@ -2297,11 +2353,12 @@ function applyTodoTimeframe(items) { // of items the list is currently filtered to. function getTodoTimeframeWindow() { const tf = getTodoTimeframeKey(); - const days = tf === "all" ? 0 : BETTER_TODO_TIMEFRAME_DAYS[tf]; + const days = getTodoTimeframeSpanDays(); const offset = (tf === "all") ? 0 : betterTodoTimeframeOffset; + const pastDays = getTodoTimeframePastDays(); const now = Date.now(); - const start = new Date(now + (offset * days * 24 * 60 * 60 * 1000)); - const end = new Date(now + ((offset + 1) * days * 24 * 60 * 60 * 1000)); + const start = new Date(now + (offset * days - pastDays) * 24 * 60 * 60 * 1000); + const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000); const fmt = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); return { tf, range: fmt(start) + " \u2013 " + fmt(end) }; } @@ -2334,9 +2391,11 @@ function updateTodoTimeframeNav() { function getTodoTimeframeBounds() { const tf = getTodoTimeframeKey(); if (tf === "all") return null; - const span = BETTER_TODO_TIMEFRAME_DAYS[tf] * 24 * 60 * 60 * 1000; + const spanDays = getTodoTimeframeSpanDays(); + const pastDays = getTodoTimeframePastDays(); const now = Date.now(); - return { start: now + (betterTodoTimeframeOffset * span), end: now + ((betterTodoTimeframeOffset + 1) * span) }; + const start = now + (betterTodoTimeframeOffset * spanDays - pastDays) * 24 * 60 * 60 * 1000; + return { start, end: start + spanDays * 24 * 60 * 60 * 1000 }; } // true when `courseId` is the dimmed-out class because another class is selected. @@ -2936,65 +2995,17 @@ function renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent) }); } -// "Fake progress display": rewrite the per-course completion entries so the -// progress readout shows exactly `anon_progress_done` out of -// `anon_progress_total`. Course totals are re-distributed proportionally to -// their real share, completion ratios get a small per-course offset (seeded, -// so rerolling varies it) so the rings look organic, and rounding drift is -// corrected so the displayed sums add up to exactly the chosen numbers. -function applyAnonProgressOverride(entries) { - const rawDone = parseInt(options.anon_progress_done); - const rawTotal = parseInt(options.anon_progress_total); - const total = isNaN(rawTotal) ? 18 : Math.max(1, Math.min(999, rawTotal)); - const done = isNaN(rawDone) ? 12 : Math.max(0, Math.min(total, rawDone)); - - // Spread the faked total across courses in proportion to their real - // share of the workload (minimum 1 each so every ring stays visible). - const realTotal = entries.reduce((s, e) => s + e.total, 0) || 1; - let assignedTotal = 0; - entries.forEach((e, i) => { - if (i === entries.length - 1) { - e.total = Math.max(1, total - assignedTotal); - } else { - e.total = Math.max(1, Math.round((total * e.total) / realTotal)); - } - assignedTotal += e.total; - }); - - // Completed counts: target ratio plus a seeded per-course offset so the - // rings don't all show the same fill. - const ratio = done / total; - let assignedDone = 0; - entries.forEach(e => { - const variation = ((anonSeededHash("ring:" + e.courseId) % 100) / 100 - 0.5) * 0.3; - e.completed = Math.max(0, Math.min(e.total, Math.round(e.total * (ratio + variation)))); - assignedDone += e.completed; - }); - - // Correct rounding drift so the sums match the chosen x/y exactly. - let drift = done - assignedDone; - while (drift !== 0) { - let changed = false; - for (const e of entries) { - if (drift > 0 && e.completed < e.total) { e.completed++; drift--; changed = true; } - else if (drift < 0 && e.completed > 0) { e.completed--; drift++; changed = true; } - if (drift === 0) break; - } - if (!changed) break; - } -} function renderProgressRings(container, scopedData) { const mode = getProgressRingMode(); if (mode === "none") { container.innerHTML = ""; return; } // Apply the same timeframe window the list pages through so the counts - // in the display match what's shown below it. Unlike the Tasks list — - // which keeps every overdue item — finished work from before the window - // is history, not current workload, so it never counts. Unfinished items - // from before the window still count on the current window (offset 0) - // because the Tasks tab shows them as overdue; later windows are strict - // slices, so the backlog only appears on the current one. + // in the display match what's shown below it. Unlike the Tasks tab — + // which keeps recently overdue work — finished work from before the + // window is history, not current workload, so it never counts. The + // window is a strict slice (from the lookback bound through the future + // edge), so the counts always match the Tasks tab exactly. const taskItems = scopedData.filter(item => isTodoTaskType(item)); const bounds = getTodoTimeframeBounds(); let allAssignments; @@ -3004,8 +3015,7 @@ function renderProgressRings(container, scopedData) { const isDone = (item) => (item.submissions?.submitted || item.planner_override?.marked_complete === true) && !isPinnedIncomplete(item); allAssignments = taskItems.filter(item => { const t = new Date(item.plannable_date).getTime(); - if (t > bounds.start && t <= bounds.end) return true; - return betterTodoTimeframeOffset === 0 && t <= bounds.start && !isDone(item); + return t > bounds.start && t <= bounds.end; }); } @@ -3785,15 +3795,16 @@ async function createTodoSections(location) { // The timeframe is a persisted Better Todo List sub-option set in the // popup. Read the current value each render so popup changes apply on // the next render. Every tab is paged through time: Tasks by due - // date, Announcements by posted date, Completed by due date. On the - // current window (offset 0) everything on/before the cutoff stays - // visible, so only paging forward narrows these two tabs. + // date, Announcements by posted date, Completed by due date. The + // Tasks tab bounds the past (1 week of history back), while + // Announcements/Completed keep their older history on the current + // window and only narrow when paged forward. // Pinned items ("sent back" submissions) bypass the window filter so // an old pinned item still shows up under Tasks. const pinnedItems = assignmentsDue.filter(item => isPinnedIncomplete(item)); - assignmentsDue = applyTodoTimeframe(assignmentsDue.filter(item => !isPinnedIncomplete(item))).concat(pinnedItems); - announcements = applyTodoTimeframe(announcements); - completed = applyTodoTimeframe(completed); + assignmentsDue = applyTodoTimeframe(assignmentsDue.filter(item => !isPinnedIncomplete(item)), false).concat(pinnedItems); + announcements = applyTodoTimeframe(announcements, true); + completed = applyTodoTimeframe(completed, true); // console.log("assignments", assignmentsDue); // console.log("announcements", announcements); // console.log("completed", completed); diff --git a/js/popup.js b/js/popup.js index 698682e..8fce63b 100644 --- a/js/popup.js +++ b/js/popup.js @@ -8,6 +8,8 @@ const syncedSubOptions = [ "todo_confetti", "todo_progress_rings", "todo_timeframe", + "todo_timeframe_custom_past", + "todo_timeframe_custom_future", "device_dark", "relative_dues", "card_overdues", @@ -74,7 +76,7 @@ const exportCardColorToggles = ["gradient_cards", "disable_color_overlay"]; const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding", "card_grid", "card_grid_columns", "card_grid_rows", "card_grid_column_gap", "card_grid_row_gap", "card_grid_center_rows", "card_grid_flex"]; const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "hide_navbar", "tab_icons"]; const exportSidebar = ["better_sidebar", "sidebar_scale"]; -const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_confetti", "todo_progress_rings", "todo_timeframe", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"]; +const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_confetti", "todo_progress_rings", "todo_timeframe", "todo_timeframe_custom_past", "todo_timeframe_custom_future", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"]; const exportGpa = ["gpa_calc", "gpa_calc_prepend", "gpa_calc_cumulative", "gpa_calc_weighted"]; const exportBackground = ["customBackgroundLink", "customBackgroundScale", "customBackgroundDaily", "customBackgroundNasaDaily", "fitImageToScreen", "card_transparency", "bg_opacity", "sidebar_opacity", "bg_blur", "sidebar_blur", "card_opacity", "card_blur"]; // Master "On/off toggles" = every visual toggle (no GPA, no dark-mode schedule, @@ -197,6 +199,8 @@ const defaultOptions = { "todo_full_height": false, "todo_progress_rings": "rings", "todo_timeframe": "all", + "todo_timeframe_custom_past": 7, + "todo_timeframe_custom_future": 14, "todo_more_expanded": false, "todo_confetti": true, "device_dark": false, @@ -411,15 +415,39 @@ function setupTodoMoreOptions(initial) { } // Timeframe dropdown for the upcoming Tasks tab. Persisted so the Better Todo -// List remembers the selected range across sessions. +// List remembers the selected range across sessions. The "custom" option +// reveals two number inputs (days ago / days from now) below the dropdown. +function setTimeframeCustomVisibility(value) { + const wrap = document.getElementById("todo_timeframe_custom"); + if (wrap) wrap.classList.toggle("visible", value === "custom"); +} + function setupTimeframeSelect(initial) { const el = document.querySelector("#todo_timeframe"); if (!el) return; - const allowed = ["all", "1week", "2week", "month", "2month"]; + const allowed = ["all", "1week", "2week", "month", "2month", "custom"]; let value = allowed.includes(initial) ? initial : "all"; el.value = value; + setTimeframeCustomVisibility(value); el.addEventListener("change", function () { chrome.storage.sync.set({ "todo_timeframe": this.value }); + setTimeframeCustomVisibility(this.value); + }); +} + +// Custom timeframe bounds (shown when Time frame is "custom"): the todo list +// window runs from X days ago to Y days in the future. Clamped to 0-365 so a +// typo can't hide every task. +function setupCustomTimeframeInput(key, initial, fallback) { + const el = document.querySelector("#" + key); + if (!el) return; + const clamp = (v) => Math.max(0, Math.min(365, v)); + const parsed = parseInt(initial); + el.value = isNaN(parsed) ? fallback : clamp(parsed); + el.addEventListener("change", function () { + const v = clamp(parseInt(this.value)); + this.value = v; + chrome.storage.sync.set({ [key]: v }); }); } @@ -1405,6 +1433,14 @@ function setup() { identifier: "todo_timeframe", setup: (initial) => setupTimeframeSelect(initial), }, + { + identifier: "todo_timeframe_custom_past", + setup: (initial) => setupCustomTimeframeInput("todo_timeframe_custom_past", initial, 7), + }, + { + identifier: "todo_timeframe_custom_future", + setup: (initial) => setupCustomTimeframeInput("todo_timeframe_custom_future", initial, 14), + }, { identifier: "anon_grade_level", setup: (initial) => setupAnonGradeSelect(initial), @@ -2291,6 +2327,8 @@ function saveCurrentTheme() { "todo_confetti": current["todo_confetti"], "todo_progress_rings": current["todo_progress_rings"], "todo_timeframe": current["todo_timeframe"], + "todo_timeframe_custom_past": current["todo_timeframe_custom_past"], + "todo_timeframe_custom_future": current["todo_timeframe_custom_future"], "todo_hr24": current["todo_hr24"], "todo_separate_scrollbar": current["todo_separate_scrollbar"], "todo_alternate_colors": current["todo_alternate_colors"],