GitHub Join Date

Displays user's join date/time/age.

  1. // ==UserScript==
  2. // @name GitHub Join Date
  3. // @description Displays user's join date/time/age.
  4. // @icon https://github.githubassets.com/favicons/favicon-dark.svg
  5. // @version 1.5
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @match https://github.com/*
  11. // @grant GM_xmlhttpRequest
  12. // @connect api.codetabs.com
  13. // @connect api.cors.lol
  14. // @connect api.allorigins.win
  15. // @connect everyorigin.jwvbremen.nl
  16. // @connect api.github.com
  17. // @require https://cdn.jsdelivr.net/npm/date-fns@4.1.0/cdn.min.js
  18. // @run-at document-idle
  19. // ==/UserScript==
  20.  
  21. ;(() => {
  22. const ELEMENT_ID = "userscript-join-date-display"
  23. const CACHE_KEY = "githubUserJoinDatesCache_v1"
  24.  
  25. let isProcessing = false
  26. let observerDebounceTimeout = null
  27.  
  28. const proxyServices = [
  29. {
  30. name: "Direct GitHub API",
  31. url: "https://api.github.com/users/",
  32. parseResponse: (response) => {
  33. return JSON.parse(response)
  34. },
  35. },
  36. {
  37. name: "CodeTabs Proxy",
  38. url: "https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/",
  39. parseResponse: (response) => {
  40. return JSON.parse(response)
  41. },
  42. },
  43. {
  44. name: "CORS.lol Proxy",
  45. url: "https://api.cors.lol/?url=https://api.github.com/users/",
  46. parseResponse: (response) => {
  47. return JSON.parse(response)
  48. },
  49. },
  50. {
  51. name: "AllOrigins Proxy",
  52. url: "https://api.allorigins.win/get?url=https://api.github.com/users/",
  53. parseResponse: (response) => {
  54. const parsed = JSON.parse(response)
  55. return JSON.parse(parsed.contents)
  56. },
  57. },
  58. {
  59. name: "EveryOrigin Proxy",
  60. url: "https://everyorigin.jwvbremen.nl/api/get?url=https://api.github.com/users/",
  61. parseResponse: (response) => {
  62. const parsed = JSON.parse(response)
  63. return JSON.parse(parsed.html)
  64. },
  65. },
  66. ]
  67.  
  68. function readCache() {
  69. try {
  70. const cachedData = localStorage.getItem(CACHE_KEY)
  71. return cachedData ? JSON.parse(cachedData) : {}
  72. } catch (e) {
  73. return {}
  74. }
  75. }
  76.  
  77. function writeCache(cacheData) {
  78. try {
  79. localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
  80. } catch (e) {
  81.  
  82. }
  83. }
  84.  
  85. function formatDate(isoDateStr) {
  86. const joinDate = new Date(isoDateStr)
  87. const now = new Date()
  88. const datePart = dateFns.format(joinDate, "dd MMM yyyy")
  89. const timePart = dateFns.format(joinDate, "HH:mm")
  90. let ageText = ""
  91. if (dateFns.differenceInYears(now, joinDate) > 0) {
  92. const years = dateFns.differenceInYears(now, joinDate)
  93. ageText = `${years} year${years !== 1 ? "s" : ""}`
  94. } else if (dateFns.differenceInMonths(now, joinDate) > 0) {
  95. const months = dateFns.differenceInMonths(now, joinDate)
  96. ageText = `${months} month${months !== 1 ? "s" : ""}`
  97. } else if (dateFns.differenceInDays(now, joinDate) > 0) {
  98. const days = dateFns.differenceInDays(now, joinDate)
  99. ageText = `${days} day${days !== 1 ? "s" : ""}`
  100. } else if (dateFns.differenceInHours(now, joinDate) > 0) {
  101. const hours = dateFns.differenceInHours(now, joinDate)
  102. ageText = `${hours} hour${hours !== 1 ? "s" : ""}`
  103. } else {
  104. const minutes = dateFns.differenceInMinutes(now, joinDate)
  105. ageText = `${minutes} minute${minutes !== 1 ? "s" : ""}`
  106. }
  107.  
  108. return `${datePart} - ${timePart} (${ageText} ago)`
  109. }
  110.  
  111. async function fetchFromApi(proxyService, username) {
  112. const apiUrl = `${proxyService.url}${username}`
  113.  
  114. return new Promise((resolve) => {
  115. if (typeof GM_xmlhttpRequest === "undefined") {
  116. console.error("GM_xmlhttpRequest is not defined. Make sure your userscript manager supports it.")
  117. resolve({ success: false, error: "GM_xmlhttpRequest is not defined" })
  118. return
  119. }
  120. GM_xmlhttpRequest({
  121. method: "GET",
  122. url: apiUrl,
  123. headers: {
  124. Accept: "application/vnd.github.v3+json",
  125. },
  126. onload: (response) => {
  127. if (response.responseText.includes("limit") && response.responseText.includes("API")) {
  128. resolve({
  129. success: false,
  130. error: "Rate limit exceeded",
  131. isRateLimit: true,
  132. })
  133. return
  134. }
  135.  
  136. if (response.status >= 200 && response.status < 300) {
  137. try {
  138. const userData = proxyService.parseResponse(response.responseText)
  139. const createdAt = userData.created_at
  140. if (createdAt) {
  141. resolve({ success: true, data: createdAt })
  142. } else {
  143. resolve({ success: false, error: "Missing creation date" })
  144. }
  145. } catch (e) {
  146. resolve({ success: false, error: "JSON parse error" })
  147. }
  148. } else {
  149. resolve({
  150. success: false,
  151. error: `Status ${response.status}`,
  152. })
  153. }
  154. },
  155. onerror: () => {
  156. resolve({ success: false, error: "Network error" })
  157. },
  158. ontimeout: () => {
  159. resolve({ success: false, error: "Timeout" })
  160. },
  161. })
  162. })
  163. }
  164.  
  165. async function getGitHubJoinDate(username) {
  166. for (let i = 0; i < proxyServices.length; i++) {
  167. const proxyService = proxyServices[i]
  168. const result = await fetchFromApi(proxyService, username)
  169.  
  170. if (result.success) {
  171. return result.data
  172. }
  173. }
  174. return null
  175. }
  176.  
  177. function removeExistingElement() {
  178. const existingElement = document.getElementById(ELEMENT_ID)
  179. if (existingElement) {
  180. existingElement.remove()
  181. }
  182. }
  183. function formatJoinDateElement(createdAtISO) {
  184. return `<strong>Joined</strong> <span style="font-weight: normal;">${formatDate(createdAtISO)}</span>`
  185. }
  186. function updateJoinDateDisplay() {
  187. const joinElement = document.getElementById(ELEMENT_ID)
  188. if (!joinElement || !joinElement.dataset.joinDate) return
  189. joinElement.innerHTML = formatJoinDateElement(joinElement.dataset.joinDate)
  190. }
  191.  
  192. async function addOrUpdateJoinDateElement() {
  193. if (document.getElementById(ELEMENT_ID) && !isProcessing) {
  194. updateJoinDateDisplay()
  195. return
  196. }
  197. if (isProcessing) {
  198. return
  199. }
  200.  
  201. const pathParts = window.location.pathname.split("/").filter((part) => part)
  202. if (
  203. pathParts.length < 1 ||
  204. pathParts.length > 2 ||
  205. (pathParts.length === 2 && !["sponsors", "followers", "following"].includes(pathParts[1]))
  206. ) {
  207. removeExistingElement()
  208. return
  209. }
  210.  
  211. const usernameElement =
  212. document.querySelector(".p-nickname.vcard-username") || document.querySelector("h1.h2.lh-condensed")
  213.  
  214. if (!usernameElement) {
  215. removeExistingElement()
  216. return
  217. }
  218.  
  219. const username = pathParts[0].toLowerCase()
  220.  
  221. isProcessing = true
  222. let joinElement = document.getElementById(ELEMENT_ID)
  223. let createdAtISO = null
  224. let fromCache = false
  225.  
  226. try {
  227. const cache = readCache()
  228. if (cache[username]) {
  229. createdAtISO = cache[username]
  230. fromCache = true
  231. }
  232.  
  233. if (!joinElement) {
  234. joinElement = document.createElement("div")
  235. joinElement.id = ELEMENT_ID
  236. joinElement.innerHTML = fromCache ? "..." : "Loading..."
  237. joinElement.style.color = "var(--color-fg-muted)"
  238. joinElement.style.fontSize = "14px"
  239. joinElement.style.fontWeight = "normal"
  240.  
  241. if (usernameElement.classList.contains("h2")) {
  242. joinElement.style.marginTop = "0px"
  243.  
  244. const colorFgMuted = usernameElement.nextElementSibling?.classList.contains("color-fg-muted")
  245. ? usernameElement.nextElementSibling
  246. : null
  247.  
  248. if (colorFgMuted) {
  249. const innerDiv = colorFgMuted.querySelector("div") || colorFgMuted
  250. innerDiv.appendChild(joinElement)
  251. } else {
  252. usernameElement.insertAdjacentElement("afterend", joinElement)
  253. }
  254. } else {
  255. joinElement.style.marginTop = "8px"
  256. usernameElement.insertAdjacentElement("afterend", joinElement)
  257. }
  258. }
  259.  
  260. if (!fromCache) {
  261. createdAtISO = await getGitHubJoinDate(username)
  262. joinElement = document.getElementById(ELEMENT_ID)
  263. if (!joinElement) {
  264. return
  265. }
  266.  
  267. if (createdAtISO) {
  268. const currentCache = readCache()
  269. currentCache[username] = createdAtISO
  270. writeCache(currentCache)
  271. } else {
  272. removeExistingElement()
  273. return
  274. }
  275. }
  276.  
  277. if (createdAtISO && joinElement) {
  278. joinElement.dataset.joinDate = createdAtISO
  279. joinElement.innerHTML = formatJoinDateElement(createdAtISO)
  280. } else if (!createdAtISO && joinElement) {
  281. removeExistingElement()
  282. }
  283. } catch (error) {
  284. removeExistingElement()
  285. } finally {
  286. isProcessing = false
  287. }
  288. }
  289.  
  290. function handlePotentialPageChange() {
  291. clearTimeout(observerDebounceTimeout)
  292. observerDebounceTimeout = setTimeout(() => {
  293. addOrUpdateJoinDateElement()
  294. }, 600)
  295. }
  296.  
  297. addOrUpdateJoinDateElement()
  298.  
  299. const observer = new MutationObserver((mutationsList) => {
  300. let potentiallyRelevantChange = false
  301. for (const mutation of mutationsList) {
  302. if (mutation.type === "childList" && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
  303. const targetNode = mutation.target
  304. if (targetNode && targetNode.matches?.("main, main *, .Layout-sidebar, .Layout-sidebar *, body")) {
  305. let onlySelfChange = false
  306. if (
  307. (mutation.addedNodes.length === 1 &&
  308. mutation.addedNodes[0].id === ELEMENT_ID &&
  309. mutation.removedNodes.length === 0) ||
  310. (mutation.removedNodes.length === 1 &&
  311. mutation.removedNodes[0].id === ELEMENT_ID &&
  312. mutation.addedNodes.length === 0)
  313. ) {
  314. onlySelfChange = true
  315. }
  316. if (!onlySelfChange) {
  317. potentiallyRelevantChange = true
  318. break
  319. }
  320. }
  321. }
  322. }
  323. if (potentiallyRelevantChange) {
  324. handlePotentialPageChange()
  325. }
  326. })
  327. observer.observe(document.body, { childList: true, subtree: true })
  328. setInterval(updateJoinDateDisplay, 60000)
  329. })()