Greasy Fork is available in English.

GitHub Join Date

Displays user's join date/time/age.

// ==UserScript==
// @name         GitHub Join Date
// @description  Displays user's join date/time/age.
// @icon         https://github.githubassets.com/favicons/favicon-dark.svg
// @version      1.2
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/userscripts/
// @supportURL   https://github.com/afkarxyz/userscripts/issues
// @license      MIT
// @match        https://github.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const ELEMENT_ID = 'userscript-join-date-display';
    const CACHE_KEY = 'githubUserJoinDatesCache_v1';
    let isProcessing = false;
    let observerDebounceTimeout = null;

    function readCache() {
        try {
            const cachedData = localStorage.getItem(CACHE_KEY);
            return cachedData ? JSON.parse(cachedData) : {};
        } catch (e) {
            return {};
        }
    }

    function writeCache(cacheData) {
        try {
            localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
        } catch (e) {
        }
    }

    function getRelativeTime(dateString) {
        const joinDate = new Date(dateString); 
        const now = new Date(); 
        const diffInSeconds = Math.round((now - joinDate) / 1000); 
        const minute = 60, hour = 3600, day = 86400, month = 2592000, year = 31536000; 
        
        if (diffInSeconds < minute) return `less than a minute ago`; 
        if (diffInSeconds < hour) { 
            const m = Math.floor(diffInSeconds / minute); 
            return `${m} ${m === 1 ? 'minute' : 'minutes'} ago`; 
        } 
        if (diffInSeconds < day) { 
            const h = Math.floor(diffInSeconds / hour); 
            return `${h} ${h === 1 ? 'hour' : 'hours'} ago`; 
        } 
        if (diffInSeconds < month) { 
            const d = Math.floor(diffInSeconds / day); 
            return `${d} ${d === 1 ? 'day' : 'days'} ago`; 
        } 
        if (diffInSeconds < year) { 
            const mo = Math.floor(diffInSeconds / month); 
            return `${mo} ${mo === 1 ? 'month' : 'months'} ago`; 
        } 
        const y = Math.floor(diffInSeconds / year); 
        return `${y} ${y === 1 ? 'year' : 'years'} ago`;
    }

    function getAbbreviatedMonth(date) {
        const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
        return months[date.getMonth()];
    }

    async function getGitHubJoinDate(user) {
        const apiUrl = `https://api.github.com/users/${user}`; 
        try { 
            const response = await fetch(apiUrl); 
            if (!response.ok) { 
                return null; 
            } 
            const userData = await response.json(); 
            return userData.created_at; 
        } catch (error) { 
            return null; 
        }
    }

    function removeExistingElement() {
        const existingElement = document.getElementById(ELEMENT_ID);
        if (existingElement) {
            existingElement.remove();
        }
    }

    async function addOrUpdateJoinDateElement() {
        if (document.getElementById(ELEMENT_ID) && !isProcessing) { return; }
        if (isProcessing) { return; }

        const pathParts = window.location.pathname.split('/').filter(part => part);
        if (pathParts.length < 1 || pathParts.length > 2 || (pathParts.length === 2 && !['sponsors', 'followers', 'following'].includes(pathParts[1]))) {
            removeExistingElement(); 
            return;
        }
        
        const usernameElement = document.querySelector('.p-nickname.vcard-username') || 
                               document.querySelector('h1.h2.lh-condensed');
        
        if (!usernameElement) {
            removeExistingElement();
            return;
        }
        
        const username = pathParts[0].toLowerCase();

        isProcessing = true;
        let joinElement = document.getElementById(ELEMENT_ID);
        let createdAtISO = null;
        let fromCache = false;

        try {
            const cache = readCache();
            if (cache[username]) {
                createdAtISO = cache[username];
                fromCache = true;
            }

            if (!joinElement) {
                joinElement = document.createElement('div');
                joinElement.id = ELEMENT_ID;
                joinElement.innerHTML = fromCache ? "..." : "Loading...";
                joinElement.style.color = 'var(--color-fg-muted)';
                joinElement.style.fontSize = '14px';
                joinElement.style.fontWeight = 'normal';
                
                if (usernameElement.classList.contains('h2')) {
                    joinElement.style.marginTop = '0px';
                    
                    const colorFgMuted = usernameElement.nextElementSibling?.classList.contains('color-fg-muted') ? 
                                         usernameElement.nextElementSibling : null;
                    
                    if (colorFgMuted) {
                        const innerDiv = colorFgMuted.querySelector('div') || colorFgMuted;
                        innerDiv.appendChild(joinElement);
                    } else {
                        usernameElement.insertAdjacentElement('afterend', joinElement);
                    }
                } else {
                    joinElement.style.marginTop = '8px';
                    usernameElement.insertAdjacentElement('afterend', joinElement);
                }
            }

            if (!fromCache) {
                createdAtISO = await getGitHubJoinDate(username);
                joinElement = document.getElementById(ELEMENT_ID);
                if (!joinElement) { return; }
                
                if (createdAtISO) {
                    const currentCache = readCache();
                    currentCache[username] = createdAtISO;
                    writeCache(currentCache);
                } else {
                    removeExistingElement(); 
                    return;
                }
            }

            if (createdAtISO && joinElement) {
                const joinDate = new Date(createdAtISO);
                const day = joinDate.getDate();
                const month = getAbbreviatedMonth(joinDate);
                const year = joinDate.getFullYear();
                const hours = joinDate.getHours().toString().padStart(2, '0');
                const minutes = joinDate.getMinutes().toString().padStart(2, '0');
                const formattedTime = `${hours}:${minutes}`;
                const relativeTimeString = getRelativeTime(createdAtISO);
                
                joinElement.innerHTML = `<strong>Joined</strong> <span style="font-weight: normal;">${day} ${month} ${year} - ${formattedTime} (${relativeTimeString})</span>`;
            } else if (!createdAtISO && joinElement) {
                removeExistingElement();
            }

        } catch (error) {
            removeExistingElement();
        } finally {
            isProcessing = false;
        }
    }

    function handlePotentialPageChange() {
        clearTimeout(observerDebounceTimeout);
        observerDebounceTimeout = setTimeout(() => {
            addOrUpdateJoinDateElement();
        }, 600);
    }

    addOrUpdateJoinDateElement();

    const observer = new MutationObserver((mutationsList) => {
        let potentiallyRelevantChange = false;
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
                const targetNode = mutation.target;
                if (targetNode && (targetNode.matches?.('main, main *, .Layout-sidebar, .Layout-sidebar *, body'))) {
                    let onlySelfChange = false;
                    if ((mutation.addedNodes.length === 1 && mutation.addedNodes[0].id === ELEMENT_ID && mutation.removedNodes.length === 0) ||
                        (mutation.removedNodes.length === 1 && mutation.removedNodes[0].id === ELEMENT_ID && mutation.addedNodes.length === 0)) {
                        onlySelfChange = true;
                    }
                    if (!onlySelfChange) { 
                        potentiallyRelevantChange = true; 
                        break; 
                    }
                }
            }
        }
        if(potentiallyRelevantChange) { 
            handlePotentialPageChange(); 
        }
    });
    observer.observe(document.body, { childList: true, subtree: true });

})();