@ -956,6 +956,10 @@ function applyOptionsChanges(changes) {
case "grade_analytics" :
watchGradeAnalytics ( ) ;
break ;
case "grade_analytics_zones" :
// Colored 10% zones on the line chart — just redraw the charts.
if ( gradeAnalyticsActive ( ) && gaOpen && gaData ) renderGradeAnalytics ( ) ;
break ;
case "quiz_safe_mode" :
// Toggling safe mode changes which features run on quiz pages; reload
// so the gating is applied cleanly.
@ -1089,6 +1093,18 @@ async function applyCustomBackground() {
background - color : color - mix ( in srgb , var ( -- bcbackground - 0 ) , transparent $ { bgTransparent } % ) ;
border - radius : 5 px ;
}
/ * N a t i v e l e f t n a v c o l u m n : # l e f t - s i d e > # s t i c k y - c o n t a i n e r . i c - s t i c k y - f r a m e
( the course / account / group menu links ) . Tint the whole # left - side column
rather than the inner . ic - sticky - frame , whose height only wraps its
links — the column spans the full viewport height like the other
sidebars , at the same bg _opacity / bg _blur as the Better Todo List
panel . Without this , a custom background ( most visible in light mode )
shows through untinted behind the nav links . * /
# left - side {
backdrop - filter : blur ( $ { bgBlur } px ) ! important ;
- webkit - backdrop - filter : blur ( $ { bgBlur } px ) ! important ;
background - color : color - mix ( in srgb , var ( -- bcbackground - 0 ) , transparent $ { bgTransparent } % ) ! important ;
}
/ * R e c e n t f e e d b a c k l i v e s i n # r i g h t - s i d e . T h e d a r k - m o d e C S S
( darkmodecss . js ) recolors its text , but those rules are dark - mode -
only — so in light mode + custom background the sub - text ( context ,
@ -1305,6 +1321,20 @@ async function applyCustomBackground() {
- webkit - backdrop - filter : blur ( $ { cardBlur } px ) saturate ( 120 % ) ! important ; `
: ` background: var(--bcbackground-0) !important; ` }
}
/ * C a r d h e a d e r s t r i p ( t h e c o u r s e - n i c k n a m e b a r u n d e r t h e h e r o ) . C a n v a s
paints it a solid light color ( $ic - color - light ) and nothing overrides
that in light mode , so with card transparency on it reads as a solid
band across an otherwise translucent card . Mirror the card surface :
transparent cards drop the strip 's background so the card' s glass
( tint + blur already applied to . ic - DashboardCard ) shows through ;
opaque cards paint it the same solid theme color as the card body .
Dark mode already flattens this strip via darkmodecss . js , so this
is inert there ( same value ) . * /
. ic - DashboardCard _ _header _content {
$ { cardTransparency
? ` background: none !important; `
: ` background: var(--bcbackground-0) !important; ` }
}
tr . student _assignment . assignment _graded . editable > * {
border : none ! important
} ` ;
@ -1727,6 +1757,17 @@ const BETTER_TODO_TIMEFRAME_DAYS = {
let betterTodoProgressFilter = null ;
let domContainers = { } ;
// Better Todo timeframe filter, shared by the task list and the progress
// display so their counts always agree: keeps items due on/before now+range
// (overdue items are before now, so they are kept too). "all" keeps
// everything.
function applyTodoTimeframe ( items ) {
betterTodoTimeframe = ( options . todo _timeframe && Object . prototype . hasOwnProperty . call ( BETTER _TODO _TIMEFRAME _DAYS , options . todo _timeframe ) ) ? options . todo _timeframe : "all" ;
if ( betterTodoTimeframe === "all" ) return items ;
const cutoff = Date . now ( ) + ( BETTER _TODO _TIMEFRAME _DAYS [ betterTodoTimeframe ] * 24 * 60 * 60 * 1000 ) ;
return items . filter ( item => new Date ( item . plannable _date ) . getTime ( ) <= cutoff ) ;
}
// true when `courseId` is the dimmed-out class because another class is selected.
function progressFilterDim ( courseId ) {
return betterTodoProgressFilter != null && String ( courseId ) !== String ( betterTodoProgressFilter ) ;
@ -2233,7 +2274,9 @@ function renderProgressRings(container, scopedData) {
const mode = getProgressRingMode ( ) ;
if ( mode === "none" ) { container . innerHTML = "" ; return ; }
const allAssignments = scopedData . filter ( item => ( item . plannable _type == "assignment" || item . plannable _type == "planner_note" ) ) ;
// Apply the same timeframe filter the list uses so the counts in the
// display match what's shown below it.
const allAssignments = applyTodoTimeframe ( scopedData . filter ( item => ( item . plannable _type == "assignment" || item . plannable _type == "planner_note" ) ) ) ;
const groups = { } ;
allAssignments . forEach ( item => {
@ -2901,13 +2944,9 @@ async function createTodoSections(location) {
completed = displayData . filter ( item => ( item . plannable _type == "assignment" || item . plannable _type == "planner_note" ) && ( item . submissions ? . submitted || item . planner _override ? . marked _complete ) ) ;
// 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. Keeps items due on/before now+range (overdue items
// are before now, so they are kept too). Only the Tasks tab is affected.
betterTodoTimeframe = ( options . todo _timeframe && Object . prototype . hasOwnProperty . call ( BETTER _TODO _TIMEFRAME _DAYS , options . todo _timeframe ) ) ? options . todo _timeframe : "all" ;
if ( betterTodoTimeframe !== "all" ) {
const cutoff = Date . now ( ) + ( BETTER _TODO _TIMEFRAME _DAYS [ betterTodoTimeframe ] * 24 * 60 * 60 * 1000 ) ;
assignmentsDue = assignmentsDue . filter ( item => new Date ( item . plannable _date ) . getTime ( ) <= cutoff ) ;
}
// the next render. Only the Tasks tab is affected (announcements and
// the completed tab always show everything).
assignmentsDue = applyTodoTimeframe ( assignmentsDue ) ;
// console.log("assignments", assignmentsDue);
// console.log("announcements", announcements);
// console.log("completed", completed);
@ -3740,8 +3779,15 @@ async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
contentMain ? . style . setProperty ( "backdrop-filter" , ` blur( ${ Math . max ( 0 , Math . min ( 30 , Number ( options . bg _blur ? ? 8 ) ) ) } px) ` , "important" ) ;
contentMain ? . style . setProperty ( "-webkit-backdrop-filter" , ` blur( ${ Math . max ( 0 , Math . min ( 30 , Number ( options . bg _blur ? ? 8 ) ) ) } px) ` , "important" ) ;
}
const sidebarParent = layoutMode === "course" && leftSide ? leftSide : mainWrapper ;
if ( layoutMode === "course" && leftSide ) {
// The rail must always render leftmost. Course-layout pages already
// prepend it into #left-side; dash-layout pages that still have a
// native left nav (accounts, groups, etc.) must too — otherwise the
// native #left-side column (made position:static above) flows before
// #not_right_side and shows up to the LEFT of the Better Sidebar,
// looking like a competing sidebar once the custom background tints
// it. Prepending keeps the order: [Better Sidebar rail][native nav].
const sidebarParent = leftSide ? leftSide : mainWrapper ;
if ( leftSide ) {
leftSide . style . display = "flex" ;
leftSide . style . flexDirection = "row" ;
leftSide . style . alignItems = "stretch" ;
@ -3749,9 +3795,6 @@ async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
leftSide . style . gap = "0" ;
}
document . querySelector ( ".ic-app-nav-toggle-and-crumbs" ) ? . style . setProperty ( "display" , "none" ) ;
if ( layoutMode !== "course" ) {
document . getElementById ( "left-side" ) ? . style . removeProperty ( "display" ) ;
}
if ( layoutMode == "dash" ) {
document . getElementById ( "header" ) ? . style . setProperty ( "display" , "none" ) ;
}
@ -6333,6 +6376,22 @@ const GA_BUCKETS = [
{ label : "0-9" , min : 0 , max : 10 , color : "#7f1d1d" } ,
] ;
const GA _UNGRADED _COLOR = "#6b7280" ;
// 5%-wide zone colors for the line chart background: the doughnut's bucket
// colors interpolated at 5% steps (dark red at 0 → green at 100), so every
// 5% band gets its own shade.
const GA _ZONE _COLORS = ( ( ) => {
const stops = GA _BUCKETS . slice ( ) . reverse ( ) ; // 0-9 (dark red) → 90+ (green)
const rgb = ( h ) => [ parseInt ( h . slice ( 1 , 3 ) , 16 ) , parseInt ( h . slice ( 3 , 5 ) , 16 ) , parseInt ( h . slice ( 5 , 7 ) , 16 ) ] ;
const lerp = ( a , b , t ) => Math . round ( a + ( b - a ) * t ) ;
return Array . from ( { length : 20 } , ( _ , i ) => {
const m = i * 5 + 2.5 ; // band midpoint
const k = Math . min ( stops . length - 1 , Math . floor ( m / 10 ) ) ;
if ( k >= stops . length - 1 ) return stops [ stops . length - 1 ] . color ;
const t = ( m - k * 10 ) / 10 ;
const c1 = rgb ( stops [ k ] . color ) , c2 = rgb ( stops [ k + 1 ] . color ) ;
return ` rgb( ${ lerp ( c1 [ 0 ] , c2 [ 0 ] , t ) } , ${ lerp ( c1 [ 1 ] , c2 [ 1 ] , t ) } , ${ lerp ( c1 [ 2 ] , c2 [ 2 ] , t ) } ) ` ;
} ) ;
} ) ( ) ;
const GA _OPEN _KEY = "grade_analytics_open" ;
async function getGradeAnalyticsOpenState ( ) {
@ -6366,37 +6425,29 @@ function gradeAnalyticsActive() {
return options . grade _analytics === true && isGradesPage ( ) && ! quizSafeModeActive ( ) ;
}
// Paginated Canvas API GET using the user's session. Unwraps Firefox Xray
// proxies like getData() does. The first page is fetched serially to learn the
// total page count from its Link header; every remaining page is then fetched
// concurrently, so courses with many assignments don't pay one round trip per
// page.
async function gaFetchAll ( path ) {
const fetchPage = async ( url ) => {
const res = await fetch ( url , { headers : { Accept : "application/json" } } ) ;
if ( ! res . ok ) throw new Error ( "Canvas API " + res . status + " for " + url . pathname ) ;
return { data : JSON . parse ( JSON . stringify ( await res . json ( ) ) ) , link : res . headers . get ( "Link" ) || "" } ;
} ;
const firstUrl = new URL ( path , domain ) ;
firstUrl . searchParams . set ( "per_page" , "100" ) ;
const { data : first , link } = await fetchPage ( firstUrl ) ;
const out = Array . isArray ( first ) ? first . slice ( ) : [ ] ;
const lastMatch = link . match ( /<([^>]+)>;\s*rel="last"/ ) ;
if ( lastMatch ) {
const lastPage = parseInt ( new URL ( lastMatch [ 1 ] ) . searchParams . get ( "page" ) || "1" , 10 ) ;
if ( lastPage > 1 ) {
const pageUrls = [ ] ;
for ( let p = 2 ; p <= lastPage ; p ++ ) {
const u = new URL ( path , domain ) ;
u . searchParams . set ( "per_page" , "100" ) ;
u . searchParams . set ( "page" , String ( p ) ) ;
pageUrls . push ( u ) ;
// Grades data is read straight from the #grades_summary table the page
// already rendered — no API round trips, so even courses with hundreds of
// assignments populate instantly, and the numbers always match what the user
// sees (grading periods, unposted grades, etc.). Waits briefly for the table
// to appear on SPA navigations.
function gaWaitForGradesTable ( timeoutMs = 15000 ) {
return new Promise ( ( resolve , reject ) => {
const ready = ( ) => {
const table = document . querySelector ( "#grades_summary" ) ;
return table && table . querySelector ( "tr.student_assignment" ) ? table : null ;
} ;
const found = ready ( ) ;
if ( found ) { resolve ( found ) ; return ; }
const started = Date . now ( ) ;
const timer = setInterval ( ( ) => {
const table = ready ( ) ;
if ( table ) { clearInterval ( timer ) ; resolve ( table ) ; }
else if ( Date . now ( ) - started > timeoutMs ) {
clearInterval ( timer ) ;
reject ( new Error ( "grades table not found on this page" ) ) ;
}
const rest = await Promise . all ( pageUrls . map ( u => fetchPage ( u ) . then ( r => r . data ) ) ) ;
for ( const page of rest ) if ( Array . isArray ( page ) ) out . push ( ... page ) ;
}
}
return out ;
} , 250 ) ;
} ) ;
}
// Entry point: called at init, on SPA navigation, and when the option changes.
@ -6561,18 +6612,18 @@ async function loadGradeAnalytics() {
if ( courseId == null ) return ;
gaLoading = true ;
const status = document . getElementById ( "canvasrefined-ga-status" ) ;
if ( status ) status . textContent = " Lo ading grade data…";
if ( status ) status . textContent = " Re ading grade data…";
try {
const [ assignments , groups ] = await Promise . all ( [
gaFetchAll ( ` /api/v1/courses/ ${ courseId } /assignments?include[]=submission&order_by=due_at ` ) ,
gaFetchAll ( ` /api/v1/courses/ ${ courseId } /assignment_groups ` ) ,
] ) ;
// Parse the grades table the page already rendered instead of hitting
// the paginated API — instant even for courses with hundreds of
// assignments, and always in sync with what the page shows.
const table = await gaWaitForGradesTable ( ) ;
gaCourseId = courseId ;
gaData = computeGradeAnalytics ( assignments , groups ) ;
gaData = computeGradeAnalytics FromPage( table ) ;
gaLoading = false ;
// renderGradeAnalytics self-guards on panel existence and canvas
// size; if the panel isn't ready yet, the retry below in
// ensureGradeAnalyticsPanel draws once it is.
// size; if the panel isn't ready yet, the retry in
// ensureGradeAnalyticsPanel / syncGradeAnalyticsUI draws once it is.
renderGradeAnalytics ( ) ;
} catch ( err ) {
logError ( err ) ;
@ -6583,74 +6634,117 @@ async function loadGradeAnalytics() {
}
}
function gaIsGraded ( a ) {
return a . published !== false && a . points _possible > 0 &&
( a . submission _types || [ ] ) . indexOf ( "not_graded" ) === - 1 &&
a . submission && a . submission . score != null ;
// Parses one assignment row of #grades_summary into a plain record. The row
// stashes the original posted score in a hidden "original_score" span (the
// "original_points" span holds points EARNED, not possible), while points
// possible is only in the "/ 15" span displayed after the grade.
function gaParseNum ( t ) {
if ( ! t ) return null ;
let s = String ( t ) . replace ( /\s+/g , "" ) ;
if ( s === "" || ! /\d/ . test ( s ) ) return null ;
// Normalize "1,234.5" (thousands grouping) and "9,5" (comma decimal).
if ( /^-?\d{1,3}(,\d{3})+(\.\d+)?$/ . test ( s ) ) s = s . replace ( /,/g , "" ) ;
else if ( /^-?\d+,\d+$/ . test ( s ) ) s = s . replace ( "," , "." ) ;
const v = parseFloat ( s ) ;
return isFinite ( v ) ? v : null ;
}
function gaIsUngraded ( a ) {
// Graded-type assignment with points possible but no score yet.
if ( a . published === false || a . points _possible <= 0 ) return false ;
if ( ( a . submission _types || [ ] ) . includes ( "not_graded" ) ) return false ;
return ! ( a . submission && a . submission . score != null ) ;
function gaParseAssignmentRow ( tr ) {
const q = ( sel ) => tr . querySelector ( sel ) ;
const titleLink = q ( ".title a" ) ;
const possibleText = q ( ".tooltip .grade + span" ) ? . textContent || "" ;
return {
title : ( titleLink ? titleLink . textContent : ( q ( "th.title" ) ? . textContent || "" ) ) . trim ( ) ,
score : gaParseNum ( q ( ".original_score" ) ? . textContent ) ,
points : gaParseNum ( possibleText . replace ( /^.*\// , "" ) ) ,
// Rows the page lists as unsubmitted/unposted carry no score; only
// "graded" rows have one.
status : ( q ( ".submission_status" ) ? . textContent || "" ) . trim ( ) ,
gid : ( q ( ".assignment_group_id" ) ? . textContent || "" ) . trim ( ) ,
due : ( q ( "td.due" ) ? . textContent || "" ) . replace ( /\s+/g , " " ) . trim ( ) ,
} ;
}
function computeGradeAnalytics ( assignments , groups ) {
function computeGradeAnalyticsFromPage ( table ) {
// Assignment group weights come from the "group total" summary rows
// (e.g. "Summative Assessment — 86.67%, weight 85").
const groupWeight = { } ;
for ( const tr of table . querySelectorAll ( "tr.group_total" ) ) {
const gid = tr . querySelector ( ".assignment_group_id" ) ? . textContent . trim ( ) ;
const w = parseFloat ( ( tr . querySelector ( ".group_weight" ) ? . textContent || "" ) . trim ( ) ) ;
if ( gid ) groupWeight [ gid ] = isFinite ( w ) ? w : 0 ;
}
const totalWeight = Object . values ( groupWeight ) . reduce ( ( s , w ) => s + w , 0 ) ;
// The page's own computed Total (e.g. "91.1%") — use it directly so the
// "Overall grade" stat always matches the page.
let pageTotal = null ;
const totalText = table . querySelector ( "tr.final_grade .grade" ) ? . textContent || "" ;
const totalMatch = totalText . match ( /-?\d+(?:\.\d+)?/ ) ;
if ( totalMatch ) pageTotal = parseFloat ( totalMatch [ 0 ] ) ;
// Rows are already listed in due-date order; skip the summary rows (they
// carry the student_assignment class too).
const rows = [ ... table . querySelectorAll ( "tr.student_assignment" ) ]
. filter ( tr => ! tr . classList . contains ( "group_total" ) && ! tr . classList . contains ( "final_grade" ) )
. map ( gaParseAssignmentRow ) ;
const counts = GA _BUCKETS . map ( ( ) => 0 ) ;
let ungraded = 0 , graded = 0 ;
for ( const a of assignments ) {
if ( gaIsUngraded ( a ) ) { ungraded ++ ; continue ; }
if ( ! gaIsGraded ( a ) ) continue ;
const pct = ( a . submission . score / a . points _possible ) * 100 ;
let ungraded = 0 ;
const graded = [ ] ;
for ( const a of rows ) {
if ( a . points == null || a . points <= 0 ) continue ; // no points possible
if ( a . score == null ) { ungraded ++ ; continue ; } // unposted / unsubmitted
graded . push ( a ) ;
const pct = ( a . score / a . points ) * 100 ;
const idx = GA _BUCKETS . findIndex ( b => pct >= b . min && pct < b . max ) ;
counts [ idx >= 0 ? idx : GA _BUCKETS . length - 1 ] ++ ;
}
const groupName = { } ;
const weight = { } ;
for ( const g of groups ) {
groupName [ g . id ] = g . name ;
weight [ g . id ] = g . group _weight || 0 ;
}
const totalWeight = Object . values ( weight ) . reduce ( ( s , w ) => s + w , 0 ) ;
// Running weighted grade in due-date order, exactly like the user sees it
// accumulate over the term.
const timeline = assignments
. filter ( gaIsGraded )
. sort ( ( x , y ) => new Date ( x . due _at || 0 ) - new Date ( y . due _at || 0 ) ) ;
// Running overall grade, in the page's row order, using Canvas's own
// weighting algorithm (GradeCalculator): sum each group's pct × weight
// over groups that have graded work, then scale up to 100% only when
// those weights total less than 100 (weights over 100 are used raw and
// can push the grade past 100). Verified to reproduce the Total shown
// on the page. Point-based courses (no group weights) use points
// earned / points possible.
const running = { } ;
const points = timeline . map ( a => {
const r = ( running [ a . assignment _group _id ] || = { score : 0 , pts : 0 } ) ;
r . score += a . submission . score ;
r . pts += a . points _possible ;
let weighted = 0 , used = 0 ;
for ( const gid of Object . keys ( running ) ) {
const g = running [ gid ] ;
if ( g . pts <= 0 ) continue ;
if ( totalWeight > 0 ) {
weighted += ( g . score / g . pts ) * ( weight [ gid ] || 0 ) ;
used += ( weight [ gid ] || 0 ) ;
} else {
weighted += g . score ;
used += g . pts ;
const pointsGrade = ( ) => {
let s = 0 , p = 0 ;
for ( const g of Object . values ( running ) ) { s += g . score ; p += g . pts ; }
return p > 0 ? ( s / p ) * 100 : null ;
} ;
const points = graded . map ( a => {
const r = ( running [ a . gid ] || = { score : 0 , pts : 0 } ) ;
r . score += a . score ;
r . pts += a . points ;
let grade = null ;
if ( totalWeight > 0 ) {
let weighted = 0 , fullWeight = 0 ;
for ( const gid of Object . keys ( running ) ) {
const g = running [ gid ] ;
if ( g . pts <= 0 ) continue ;
const w = groupWeight [ gid ] || 0 ;
weighted += ( g . score / g . pts ) * w ;
fullWeight += w ;
}
// Only zero-weighted groups have graded work — fall back to
// points so the chart still has a line.
grade = fullWeight > 0 ? ( fullWeight < 100 ? ( weighted / fullWeight ) * 100 : weighted ) : pointsGrade ( ) ;
} else {
grade = pointsGrade ( ) ;
}
const grade = used > 0 ? ( weighted / used ) * 100 : null ;
const pct = ( a . submission . score / a . points _possible ) * 100 ;
return {
title : a . name ,
score : a . s ubmission. s core,
points : a . points _possible ,
pct ,
title : a . title ,
score : a . s core,
points : a . points ,
pct : ( a . score / a . points ) * 100 ,
grade ,
group : groupName [ a . assignment _group _id ] || "" ,
due : a . due _at ? new Date ( a . due _at ) . toLocaleDateString ( ) : "" ,
due : a . due ,
} ;
} ) ;
const pcts = timeline . map ( a => ( a . s ubmission. s core / a . points _possible ) * 100 ) ;
const pcts = graded . map ( a => ( a . s core / a . points ) * 100 ) ;
// Trend: change in the running overall grade over the last 5 graded
// assignments (or since the first, if fewer). Positive = climbing.
let trend = null ;
@ -6662,9 +6756,9 @@ function computeGradeAnalytics(assignments, groups) {
return {
counts ,
ungraded ,
graded : pcts . length ,
graded : graded . length ,
avg : pcts . length ? pcts . reduce ( ( s , p ) => s + p , 0 ) / pcts . length : null ,
current : p oints. length ? points [ points . length - 1 ] . grade : null ,
current : p ageTotal != null ? pageTotal : ( p oints. length ? points [ points . length - 1 ] . grade : null ) ,
trend ,
points ,
} ;
@ -6691,8 +6785,8 @@ function renderGradeAnalytics() {
stats . innerHTML =
stat ( "Overall grade" , gaData . current == null ? "-" : gaData . current . toFixed ( 1 ) + "%" ) +
stat ( "Grade trend (last 5)" , trendVal , trendColor ) +
stat ( "Graded assignments ", gaData . graded ) +
stat ( "Ungraded / no score ", gaData . ungraded ) ;
stat ( "Graded ", gaData . graded ) +
stat ( "Ungraded ", gaData . ungraded ) ;
const charts = panel . querySelector ( "#canvasrefined-ga-charts" ) ;
charts . style . display = "flex" ;
@ -6842,6 +6936,22 @@ function gaDrawLine(canvas, tooltip) {
// can cheaply redraw with a highlight on the active dot.
const draw = ( hover ) => {
ctx . clearRect ( 0 , 0 , w , h ) ;
// Optional colored 5% zones behind the plot ("Colored grade zones"
// popup option), tinted red→green. With Fit Y axis on, bands are
// clipped to the visible range.
if ( options . grade _analytics _zones ) {
GA _ZONE _COLORS . forEach ( ( color , i ) => {
const top = Math . min ( yMax , ( i + 1 ) * 5 ) ;
const bottom = Math . max ( yMin , i * 5 ) ;
if ( top <= bottom ) return ;
const y1 = pad . t + ( 1 - ( top - yMin ) / ( yMax - yMin ) ) * ( h - pad . t - pad . b ) ;
const y2 = pad . t + ( 1 - ( bottom - yMin ) / ( yMax - yMin ) ) * ( h - pad . t - pad . b ) ;
ctx . globalAlpha = 0.3 ;
ctx . fillStyle = color ;
ctx . fillRect ( pad . l , y1 , w - pad . l - pad . r , y2 - y1 ) ;
ctx . globalAlpha = 1 ;
} ) ;
}
// Y grid: 5 evenly spaced lines across the current range.
ctx . font = "11px Lato, sans-serif" ;
ctx . textAlign = "right" ; ctx . textBaseline = "middle" ;
@ -6866,14 +6976,62 @@ function gaDrawLine(canvas, tooltip) {
ctx . fillStyle = "#2563eb" ; ctx . fill ( ) ;
ctx . strokeStyle = "#fff" ; ctx . lineWidth = 1 ; ctx . stroke ( ) ;
} ) ;
// X labels: first, middle, last due dates.
// X axis: tick marks plus one label per calendar month. Points are
// already in chronological order, so a walking month counter (wrapping
// across Dec -> Jan) maps each point to an absolute month; the first
// point of each month gets the tick + label.
ctx . textAlign = "center" ; ctx . textBaseline = "top" ;
[ [ 0 ] , [ Math . floor ( ( pts . length - 1 ) / 2 ) ] , [ pts . length - 1 ] ] . forEach ( ( [ i ] ) => {
if ( pts . length > 1 && pts . length > 2 && i !== 0 && i !== pts . length - 1 && Math . abs ( X ( i ) - X ( 0 ) ) < 40 ) return ;
const label = pts [ i ] . due || "" ;
if ( ! label ) return ;
ctx . strokeStyle = "rgba(128,128,128,0.55)" ;
ctx . lineWidth = 1 ;
const tick = ( x ) => {
ctx . beginPath ( ) ; ctx . moveTo ( x , h - pad . b ) ; ctx . lineTo ( x , h - pad . b + 4 ) ; ctx . stroke ( ) ;
} ;
// Subtle tick under every data point on sparse charts.
if ( pts . length <= 25 ) pts . forEach ( ( _ , i ) => tick ( X ( i ) ) ) ;
// Month boundaries: the first point of each calendar month. Points are
// already in chronological order, so a walking month counter (wrapping
// across Dec → Jan) maps each point to an absolute month.
const boundaries = [ ] ;
let prevM = null , absM = null ;
pts . forEach ( ( p , i ) => {
const m = months . indexOf ( ( p . due || "" ) . trim ( ) . slice ( 0 , 3 ) ) ;
if ( m < 0 ) return ; // no due date on this point
if ( absM == null ) absM = m ;
else if ( m >= prevM ) absM += m - prevM ;
else absM += 12 - prevM + m ; // wrapped to a new year
prevM = m ;
const last = boundaries [ boundaries . length - 1 ] ;
if ( ! last || last . absM !== absM ) {
boundaries . push ( { i , absM , label : ( p . due || "" ) . trim ( ) . slice ( 0 , 3 ) } ) ;
}
} ) ;
boundaries . forEach ( b => tick ( X ( b . i ) ) ) ;
// Collision-aware labels: greedily keep a month label only if it fits
// after the previous kept one (labels are centered, so compare against
// half-widths plus a 6px gap). The first boundary always gets a label;
// so does the last — if it doesn't fit, earlier labels are dropped to
// make room, so the axis ends on a real month instead of mid-run.
const GAP = 6 ;
const kept = [ ] ;
boundaries . forEach ( ( b , idx ) => {
const x = X ( b . i ) ;
const w = ctx . measureText ( b . label ) . width ;
if ( idx === 0 || x - w / 2 > kept [ kept . length - 1 ] . right + GAP ) {
kept . push ( { ... b , x , right : x + w / 2 } ) ;
}
} ) ;
const lastB = boundaries [ boundaries . length - 1 ] ;
if ( lastB && kept [ kept . length - 1 ] . i !== lastB . i ) {
const x = X ( lastB . i ) ;
const w = ctx . measureText ( lastB . label ) . width ;
while ( kept . length && kept [ kept . length - 1 ] . right + GAP > x - w / 2 ) kept . pop ( ) ;
kept . push ( { ... lastB , x , right : x + w / 2 } ) ;
}
kept . forEach ( ( b , k ) => {
// Anchor the edge labels inward so they don't clip.
ctx . textAlign = k === 0 ? "left" : ( b . i === pts . length - 1 ? "right" : "center" ) ;
ctx . fillStyle = text ;
ctx . fillText ( label , X ( i ) , h - pad . b + 6 ) ;
ctx . fillText ( b. label , b . x , h - pad . b + 6 ) ;
} ) ;
// Hover indicator: dashed vertical guide plus a halo ring around the
// hovered dot so it's obvious which point the tooltip describes.