diff --git a/tdx-enhanced.js b/tdx-enhanced.js index 61d65f5..f5b83a7 100644 --- a/tdx-enhanced.js +++ b/tdx-enhanced.js @@ -19,16 +19,50 @@ // @run-at document-end // ==/UserScript== +/** + * TDX Userscript — maintainer overview + * + * Purpose: turns the standard TeamDynamix/TDNext interface into a more legible, + * configurable Purdue IT workspace. It supplies light/dark presentation, + * themes, contextual queue/status/date highlights, browser-native date inputs, + * TDX Tools and IT Status header shortcuts, tab-oriented navigation, dashboard + * layouts, sticky columns, and optional embedded dashboard websites. + * + * Runtime dependencies: Tampermonkey executes this file and exposes GM_addStyle; + * Moment.js parses/formats TDX dates; tinycolor improves dark-mode contrast. + * TeamDynamix IDs and CSS classes are implementation dependencies—verify the + * selectors when TDX changes its UI. + * + * Preferences: localStorage.userSettings holds settings per browser. The wrench + * menu is added beside #globalSearchBar. Most settings take effect immediately, + * but independently rendered TDX components may need a page refresh. + * + * Dashboard embeds: create a dummy Desktop report named, for example, + * `{"name":"Clock","url":"https://example.edu/clock"}`. It needs an Age + * column plus an `Age equals -1` filter; updateHeading replaces its widget with + * an iframe. The linked site must allow iframe embedding. + */ (function() { 'use strict'; + /** + * TeamDynamix enhancement entry point. The script relies on Moment.js for + * date handling, tinycolor for feed contrast, Tampermonkey's GM_addStyle + * for page CSS, and TDX's current DOM/class names. User preferences live + * in localStorage under `userSettings`. + */ + + // Product name displayed in the TeamDynamix header after the page loads. const primaryTitle = "Purdue University IT" /* QUEUE COLORS */ - //from main webqueue script + /** + * Visual identity for known queues. Keys must be lowercase because callers + * normalize queue labels before using this lookup. + */ var colorsByQueue = { 'aae': {'bg' : '#7030A0', 'txt' : 'white' }, 'abe' : {'bg' : '#256f78', 'txt' : 'white'}, @@ -87,6 +121,10 @@ 'zsite' : {'bg' : '#98A4AE', 'txt' : 'white'}, }; + /** + * Status pill colors for the standard TeamDynamix report-table statuses. + * Alpha-channel backgrounds stay readable against either theme. + */ var colorsByTdxStatus = { 'In Process': {'bg' : '#FF3C3C20', 'txt' : 'var(--txt-1)', 'border' : '#FF3C3C50'}, 'New': {'bg' : '#FF3C3C20', 'txt' : 'var(--txt-1)', 'border' : '#FF3C3C50'}, @@ -96,14 +134,20 @@ 'Pending Internal': {'bg' : '#FF990020', 'txt' : 'var(--txt-1)', 'border' : '#FF990050'}, }; + // Trusted origin for the companion TDX Tools and highlight-editor pages. var tdxtoolsUrl = "https://engineering.purdue.edu" - //regex for matching inline highlights + /** + * Built-in inline markers. For example, `!! urgent !!` is rendered using + * highlight 1. The regular expressions are global, so their lastIndex is + * reset after a successful use below. + */ var colorsByStatus = { '!!': {style: {background: 'var(--col-highlight-1)'}, type: 'highlight', re: new RegExp("\!! (.*) \!!","g")}, '~~': {style: {background: 'var(--col-highlight-2)'}, type: 'highlight', re: new RegExp("\~~ (.*) \~~","g")}, } + // The active page color mode, set by setColorMode and read while styling feeds. var colorScheme /* BEGIN FUNCTIONS */ @@ -111,6 +155,10 @@ //color manipulation via css filters: https://github.com/angel-rs/css-color-filter-generator //modified to return an object instead of filter string + /** + * Mutable RGB color used to simulate the CSS filter pipeline. Values are + * clamped to 0-255 after every transformation. + */ class Color { constructor(r, g, b) { this.set(r, g, b); @@ -129,6 +177,7 @@ } hueRotate(angle = 0) { + // Applies the same 3×3 RGB matrix used by CSS hue-rotate(). angle = (angle / 180) * Math.PI; const sin = Math.sin(angle); const cos = Math.cos(angle); @@ -147,6 +196,7 @@ } grayscale(value = 1) { + // Interpolates between the original color and grayscale. this.multiply([ 0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), @@ -161,6 +211,7 @@ } sepia(value = 1) { + // Interpolates between the original color and a sepia transform. this.multiply([ 0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), @@ -175,6 +226,7 @@ } saturate(value = 1) { + // Multiplies saturation using CSS-compatible coefficients. this.multiply([ 0.213 + 0.787 * value, 0.715 - 0.715 * value, @@ -189,6 +241,7 @@ } multiply(matrix) { + // Applies a row-major 3×3 transformation matrix in place. const newR = this.clamp( this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2] ); @@ -204,9 +257,11 @@ } brightness(value = 1) { + // CSS brightness is a linear multiplication with no offset. this.linear(value); } contrast(value = 1) { + // CSS contrast expands values around the midpoint (128). this.linear(value, -(0.5 * value) + 0.5); } @@ -217,12 +272,14 @@ } invert(value = 1) { + // `value` is normalized (0-1), matching the CSS invert() function. this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255); this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255); this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255); } hsl() { + // Produces HSL for the solver's color-distance calculation. // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA. const r = this.r / 255; const g = this.g / 255; @@ -271,14 +328,22 @@ } } + /** + * Searches for CSS filter values that transform black icons to `target`. + * This is adapted from css-color-filter-generator and returns structured + * values rather than a ready-to-inject `filter:` declaration. + */ class Solver { constructor(target, baseColor) { this.target = target; + // Compare RGB and HSL so hue/saturation mismatches cannot look "good". this.targetHSL = target.hsl(); + // Reuse one object for optimizer iterations to avoid allocation churn. this.reusedColor = new Color(0, 0, 0); } solve() { + // First find a broad approximation, then refine it locally. const result = this.solveNarrow(this.solveWide()); return { values: result.values, @@ -288,6 +353,7 @@ } solveWide() { + // Repeated broad SPSA passes avoid depending on one random start. const A = 5; const c = 15; const a = [60, 180, 18000, 600, 1.2, 1.2]; @@ -304,6 +370,7 @@ } solveNarrow(wide) { + // Narrow search starts from the best broad-search filter values. const A = wide.loss; const c = 2; const A1 = A + 1; @@ -312,6 +379,7 @@ } spsa(A, a, c, values, iters) { + // Simultaneous Perturbation Stochastic Approximation optimizer. const alpha = 1; const gamma = 0.16666666666666666; @@ -331,6 +399,7 @@ const lossDiff = this.loss(highArgs) - this.loss(lowArgs); + // Estimate the gradient from two simultaneous perturbations. for (let i = 0; i < 6; i++) { const g = (lossDiff / (2 * ck)) * deltas[i]; const ak = a[i] / Math.pow(A + k + 1, alpha); @@ -354,6 +423,7 @@ } if (idx === 3 /* hue-rotate */) { + // Hue is circular: 101% and 1% represent the same rotation. if (value > max) { value %= max; } else if (value < 0) { @@ -369,10 +439,12 @@ } loss(filters) { + // Lower loss means both RGB and HSL values more closely match target. // Argument is array of percentages. const color = this.reusedColor; color.set(0, 0, 0); + // CSS filters below run in the same order as the generated CSS rule. color.invert(filters[0] / 100); color.sepia(filters[1] / 100); color.saturate(filters[2] / 100); @@ -392,6 +464,7 @@ } css(filters) { + // Converts optimizer percentages to CSS custom-property values. function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); } @@ -412,6 +485,7 @@ } } + /** Converts three- or six-digit hexadecimal notation to an [r, g, b] array. */ function hexToRgb(hex) { // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; @@ -420,6 +494,7 @@ }); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + // null signals an invalid CSS color to callers. return result ? [ parseInt(result[1], 16), @@ -429,6 +504,10 @@ : null; } + /** + * Runs several independent solver attempts and retains the closest result. + * Kept for callers that need a higher-quality (but more expensive) search. + */ function getFilterForColor(rgbColor) { let bestResult = null; let iterationCount = 0; // Initialize a counter for iterations @@ -454,6 +533,13 @@ //end color manipulation + /** + * Processes dashboard-widget titles. A JSON report title such as + * `{ "name": "Clock", "url": "https://…" }` is an embed instruction: + * the title becomes a readable heading and its `.tdx-widget` is replaced by + * an iframe. Invalid JSON falls through without breaking the dashboard. + * Non-embed titles remain eligible for custom report highlights. + */ function updateHeading(mutation) { let headings = mutation.querySelectorAll(".tdx-control-bar__title") @@ -475,6 +561,7 @@ let title = document.createElement("h4") title.classList = heading.classList title.innerText = embedData.name + // TDX nests the title three levels beneath the widget body. let moduleBody = heading.parentNode.parentNode.parentNode.querySelector(".tdx-widget") let embed = document.createElement("iframe") embed.classList = "customEmbed" @@ -487,6 +574,15 @@ } } + /** + * Enhances a ticket/task page and, when `mutation` is supplied, newly + * inserted feed nodes. It replaces legacy date inputs, labels the queue, + * changes the browser title, normalizes action links, and improves dark-mode + * contrast for inline feed colors. Legacy `#txtStartDate` / `#txtEndDate` + * inputs are retained but hidden because TDX still needs their original + * `M/D/YYYY h:mm A` values when the form is submitted. The displayed + * datetime-local input mirrors changes back to that legacy field. + */ function parseTicket(mutation=null) { let qBlock = document.querySelector("#ctlAttribute2285") @@ -500,6 +596,7 @@ let originalFormat = "M/D/YYYY h:mm A"; let newFormat = "YYYY-MM-DDTHH:mm"; [...calendars].forEach(calendar=>{ + // Parse the value exactly as the legacy TDX control serializes it. let date = moment(calendar.value,originalFormat) let newCal = document.createElement("input") @@ -508,11 +605,11 @@ newCal.type = "datetime-local" function updateDate() { - //update old tdx calendar + // Keep the hidden TDX field submit-ready in its expected format. let parsedFormat = date.format(originalFormat) calendar.value = parsedFormat - //update new calendar + // Keep the browser-native control and its friendly weekday label in sync. let iso = date.format(newFormat) newCal.value = iso calTxt.innerText = date.calendar() @@ -542,7 +639,7 @@ date.set(btn.set) } - //check business days + // Avoid scheduling the shortcut result outside the support workweek. let day = date.isoWeekday() let hour = date.hour() let min = date.minute() @@ -552,13 +649,14 @@ //if Friday after 5pm if (day>=5 && (hour>17 || (hour==17 && min>0))) { + // Friday evening (or later) moves to Monday at 8:00 AM. date.set({ h: 8, d: 1+7, m: 0 }) } else if (day>5) { - //if sat/sun + // Saturday/Sunday selections also move to the following Monday. date.set({ d: 1+7 }) @@ -599,6 +697,7 @@ //change tab title if (header) { + // Use the ticket subject for easier identification among browser tabs. let ticketTitle = header.childNodes[0].textContent document.title = ticketTitle } else { @@ -616,6 +715,7 @@ newBox.innerText = qTxt newBox.classList.add("qBox") + // Only known queues receive a color; unknown names retain default styling. for (const qK of Object.keys(colorsByQueue)) { let q = colorsByQueue[qK] if (qTxt.toLowerCase() == qK) { @@ -665,6 +765,7 @@ //if color is too dark if (brightness < 100 && colorScheme == "darkMode") { + // TDX may supply dark inline text that disappears on dark surfaces. var newColor var isGrey = true @@ -678,10 +779,12 @@ //if color is shade of grey if (isGrey) { + // Rotate then invert neutral tones to create a readable contrast. let flippedColor = tinycolor(invertHex(color.spin(180).toHex())) newColor = flippedColor //console.log(feedElement.innerText,"is grey") } else { + // Preserve a colored label's hue while increasing its brightness. let threshold = 50 let diff = (threshold - brightness) + brightness newColor = color.brighten(diff) @@ -696,6 +799,11 @@ } } + /** + * Applies enhancements outside ticket content: styles accessible shadow DOM, + * enforces readable inline text, optionally hides AI Assist, changes the + * dashboard layout, and makes requested dashboard columns sticky. + */ function parseOtherElements() { let path = document.location.pathname; @@ -719,10 +827,11 @@ }) //force contrastive colors in descriptions with inline styles + // Do not alter anchors: their color conveys normal link affordance. document.querySelectorAll("div.moreToggle *:not(a), div.lessToggle *:not(a), div.wrap-text *:not(a)").forEach(textBlock=>{ textBlock.style.color = "var(--txt-1)" }) - document.querySelectorAll("#ToggleAIAgentAssist").forEach(AIAgentButton=>{ + document.querySelectorAll("#ToggleAIAgentAssist").forEach(AIAgentButton=>{ let selected = settings('get','aiBehavior'); console.log("AI Behavior selected:",selected.value) if (selected == "aiDisable") { @@ -769,6 +878,7 @@ //apply sticky columns let stickyColumns = settings('get','stickyColumns') if (stickyColumns) { + // Sticky positioning is useful only when columns differ in height. [...document.querySelectorAll(".tdx-dashboard__column")].forEach(column=>{ column.style.position = "sticky" column.style.alignSelf = "flex-start" @@ -777,10 +887,16 @@ } } + /** Returns the RGB inverse of a six-character hexadecimal color (without #). */ function invertHex(hex) { return (Number(`0x1${hex}`) ^ 0xFFFFFF).toString(16).substr(1).toUpperCase() } + /** + * Converts a TDX table into row objects keyed by visible column headings, + * then delegates each row to parseItem. Checkbox cells are intentionally + * skipped so their position does not affect the heading-to-cell mapping. + */ function parseTable(element) { //let t = mutation.target; //console.error("BEGIN MUTATION",t) @@ -817,6 +933,7 @@ //console.log("Cell:",i,cell); + // Retain text and its source cell so formatting can mutate the DOM. item[headers[i]] = {txt:txt,cell:cell} i++ }) @@ -833,6 +950,10 @@ //updateHeading(element) } + /** + * Replaces an element's content with a styled, text-only highlight badge. + * Use this only where discarding nested markup/links is acceptable. + */ function createHighlightBubble(element,bgColor,txtColor,borderColor = "#00000000") { //should the bubble carry links over? @@ -854,7 +975,11 @@ return newSpan } - //modify/color the cells + /** + * Applies all row-level rules: queue/status badges, human-readable dates, + * age and due-date urgency, reply/internal-update cues, person/title + * highlights, and report link behavior. + */ function parseItem(item) { //console.log("Parse item:",item) @@ -886,6 +1011,7 @@ let dTxt = item[dType].txt let dCell = item[dType].cell + // `fromNow` turns server timestamps into compact list-view age text. let date = moment(dTxt) let dTxtNew = date.fromNow() @@ -903,6 +1029,7 @@ let dTxt = item[dType].txt let dCell = item[dType].cell + // `calendar` gives a more actionable label such as "Tomorrow at 5:00 PM". let date = moment(dTxt) let dTxtNew = date.calendar() @@ -919,6 +1046,7 @@ for (const dType of modifiedDates) { if (dType in item) { let modDate = item[dType] + // Fade from clear to full warning red over 14 days (336 hours). const ageThreshold = 336 let date = moment(modDate.txt) @@ -939,6 +1067,7 @@ for (const dType of dueDates) { if (dType in item) { let dueDate = item[dType] + // Due dates fade in across the five days preceding their deadline. const ageThreshold = -5*24 let date = moment(dueDate.txt) @@ -951,7 +1080,7 @@ } else { alpha = 1 } - + if (!Number.isNaN(hours)) { let cell = dueDate.cell handleHighlight("dateModified",alpha,cell) @@ -978,7 +1107,7 @@ } } } - + // let Status = ['Status'] // for (const dType of Status) { // if (dType in item) { @@ -996,6 +1125,7 @@ //reply from user if (fromUser.txt == lastModified.txt && fromUser.txt != assignedTo.txt && assignedTo.txt != "Unassigned") { + // The requester, rather than an assignee, supplied the latest update. //item.row.style.backgroundColor = "var(--col-reply)"; handleHighlight("reply",null,item.row) } @@ -1035,7 +1165,14 @@ } } + /** + * Applies the user's preferred navigation behavior to a report or ticket + * action. In tab mode, TDX's default pop-up handlers are replaced with a + * centered new-tab/window call; the dormant alternate branch preserves the + * original pop-up implementation for future use. + */ function handleLink(source,link) { + // `tabs` is the documented preferred behavior; other values preserve TDX defaults. let behavior = settings('get','linkBehavior') let relL = window.screenLeft != undefined ? window.screenLeft : screen.left @@ -1045,6 +1182,7 @@ switch(source) { case 'report': { + // Remove TDX's popup handler before making the anchor a normal new tab. link.onclick = null link.target = "_blank" break @@ -1065,6 +1203,7 @@ let baseHref = location.substr(0, location.lastIndexOf('/')) let href = `${baseHref}/${source}${window.location.search}` + // Window geometry is retained for browsers that honor popup features. let params = `width=${w}px,height=${h}px,left=${l}px,top=${t}px` return window.open(href,'_blank',params) } @@ -1110,12 +1249,22 @@ } + /** + * Resolves built-in and user-defined highlight rules and applies their CSS. + * `type` identifies semantic context; `txt` is either cell text or a + * 0-1 opacity value for age/date rules. Custom-highlight entries supplied by + * the editor use `{type, value, style}`: type can be `highlight`, `reply`, + * `dateModified`, `userModified`, `report`, `report-regex`, or `person`; + * `style` is assigned directly to element.style. Highlight behavior controls whether + * matching marker text is removed from the whole cell or only its link text. + */ function handleHighlight(type, txt, element) { let behavior = settings('get','highlightBehavior') var re var style = null + // Built-in `!! … !!`/`~~ … ~~` markers establish the initial style. for (const [key,color] of Object.entries(colorsByStatus)) { re = color.re.exec(txt) if (re) { @@ -1124,6 +1273,7 @@ } } + // User-defined rules run after built-ins and can therefore override them. const customHighlights = settings('get','customHighlights') || [] for (const customHighlight of customHighlights) { let customType = customHighlight.type @@ -1144,6 +1294,7 @@ if (type=="dateModified" && customType=="dateModified") { style = customHighlight.style if (style.background) { + // Append an alpha byte to a hex background based on urgency. let a = Math.floor(txt * 255).toString(16); style.background = style.background + a } @@ -1179,7 +1330,8 @@ //console.log("Apply custom highlight:",txt) let link = element.querySelector("a") - if (behavior==="block") { + if (behavior==="block") { + // Block mode removes marker characters from the link label. if (re && link) { let newTitle = re[1] link.innerText = newTitle @@ -1226,10 +1378,15 @@ } } + /** Reserved extension point for a shared pop-up creator; currently unused. */ function generatePopup(href,w,h,l,t,source) { } + /** + * Adds the generated CSS to a shadow root or iframe document once. For + * iframe editors it also applies the current color-mode class to the frame. + */ function injectOtherStyles(element) { let s = document.createElement("style") @@ -1238,6 +1395,7 @@ //find iframes - likely text editors if (element.tagName == "IFRAME") { + // Iframes have a separate document; page styles do not cross this boundary. let frame = element.contentWindow.document; //attempt color coded tables on non-desktop pages @@ -1256,6 +1414,7 @@ head.appendChild(s) } } else { + // Shadow roots accept a style element directly but cannot see page CSS. if (!element.querySelector("#customStyles")) { setCssFilters(s) element.appendChild(s) @@ -1263,6 +1422,7 @@ } } + /** Injects this userscript's styles and readable text color into CKEditor. */ function ckeinjectCustomCss(event) { // var editor = event.editor try { @@ -1278,20 +1438,24 @@ } catch(error) {} } - //setup observer to watch report/table changes/refreshes + /** + * Keeps enhancements active in TDX's single-page UI as it replaces feed, + * dashboard, side-panel, and rich-text-editor content. + */ let observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { let t = mutation.target if (t.querySelector("div > table")) { //let table = t.firstElementChild.querySelector("table") //console.log("Matched table",t) - - //potential cause of freezing issue + + //potential cause of freezing issue //parseTable(t) } //parse the feed if it updates if (t.classList.contains("feed")) { + // Only process added feed content rather than reparsing the whole ticket. parseTicket(mutation.addedNodes) } else if (t.classList.contains("feed-body")) { parseTicket(t.querySelectorAll(".feed-reply")) @@ -1311,13 +1475,14 @@ if (t.classList.contains("tdx-right-side-panel")) { parseOtherElements() } - + + // A new widget container may contain an embed-style report title. let module = t.querySelector("div > .tdx-dashboard__widget-container") if (module) { updateHeading(t) } - try { + try { for (const instance in CKEDITOR.instances) { ckeinjectCustomCss(CKEDITOR.instances[instance]); } @@ -1338,11 +1503,13 @@ observer.observe(document.body, { characterDataOldValue: false, subtree: true, + // Child-list changes cover TDX's dynamically rendered view updates. childList: true, characterData: false }); } + /** Sets the product header text, then inserts the toolbar controls. */ async function changeTitle() { let title = document.querySelector(".tdx-headerbar-headline a") if (title) { @@ -1352,11 +1519,16 @@ await injectToolbar() } + /** Stores a color choice and immediately applies the corresponding CSS value. */ function colorChange(color) { localStorage.setItem("colors",color) setColors(color) } + /** + * Updates CSS custom properties for a custom accent/background or a named + * theme. `value` is either a CSS color or a theme suffix such as `gold`. + */ function setColors(color,value) { console.log("Setting",color,"to",value) //let col = tinycolor(value).toRgbString() @@ -1389,10 +1561,16 @@ } } + /** + * Calculates filters that recolor monochrome icons to the current accent. + * When an element is supplied, its inline style receives the filter vars; + * otherwise they are stored on the document root. + */ function setCssFilters(element=null) { let style = element ? element.style : document.documentElement.style //quick test for filter generation let mode = getColorMode().split("Mode")[0] + // Filters start from black; derive target RGB from the active primary color. let priCol = window.getComputedStyle(document.body).getPropertyValue("--dark-col-primary") let rgb = hexToRgb(priCol) //let filters = getFilterForColor(rgb) @@ -1421,11 +1599,16 @@ */ + /** Returns the operating system's current light/dark preference. */ function getColorMode() { let mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? "darkMode" : "lightMode" return mode } + /** + * Activates an explicit or automatic color mode. Automatic mode follows the + * OS and is persisted as `auto` when it matches the current preference. + */ function setColorMode(mode,store=true) { if (!mode) { mode = "auto" @@ -1440,6 +1623,7 @@ } if (mode == "auto" || storageMode == "auto") { + // No explicit class lets the CSS media queries follow the OS preference. mode = autoScheme document.documentElement.classList.remove("lightMode") document.documentElement.classList.remove("darkMode") @@ -1459,9 +1643,14 @@ }) } catch (error) {} + /** + * Builds the TDX Tools link, service-status control, and settings menu, + * attaches their listeners, then populates the current IT status. + */ async function injectToolbar() { let iconBar = document.querySelector("#globalSearchBar") + // Kept as strings so a single insertion creates the complete status control. let statusHTML = `
` + // Form names intentionally match localStorage setting keys used by settings(). let settingsHTML = `