GitHub Join Date

Displays user's join date/time/age.

Per 11-05-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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

(function() {
    'use strict';

    const ELEMENT_ID = 'userscript-join-date-display';
    const CACHE_KEY = 'githubUserJoinDatesCache_v1';
    const GITHUB_API_BASE = 'https://api.github.com/users/';
    const FALLBACK_API_BASE = 'https://api.codetabs.com/v1/proxy/?quest=https://api.github.com/users/';
    
    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 fetchFromGitHubApi(username) {
        const apiUrl = `${GITHUB_API_BASE}${username}`;
        
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl,
                headers: {
                    'Accept': 'application/vnd.github.v3+json'
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const userData = JSON.parse(response.responseText);
                            const createdAt = userData.created_at;
                            if (createdAt) {
                                resolve({ success: true, data: createdAt });
                            } else {
                                resolve({ success: false, error: 'Missing creation date' });
                            }
                        } catch (e) {
                            resolve({ success: false, error: 'JSON parse error' });
                        }
                    } else {
                        resolve({ 
                            success: false, 
                            error: `Status ${response.status}`, 
                            useProxy: response.status === 403 || response.status === 429
                        });
                    }
                },
                onerror: function() {
                    resolve({ success: false, error: 'Network error', useProxy: true });
                },
                ontimeout: function() {
                    resolve({ success: false, error: 'Timeout', useProxy: true });
                }
            });
        });
    }

    async function fetchFromProxyApi(username) {
        const apiUrl = `${FALLBACK_API_BASE}${username}`;
        
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl,
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const userData = JSON.parse(response.responseText);
                            const createdAt = userData.created_at;
                            if (createdAt) {
                                resolve({ success: true, data: createdAt });
                            } else {
                                resolve({ success: false, error: 'Missing creation date' });
                            }
                        } catch (e) {
                            resolve({ success: false, error: 'JSON parse error' });
                        }
                    } else {
                        resolve({ success: false, error: `Status ${response.status}` });
                    }
                },
                onerror: function() {
                    resolve({ success: false, error: 'Network error' });
                },
                ontimeout: function() {
                    resolve({ success: false, error: 'Timeout' });
                }
            });
        });
    }

    async function getGitHubJoinDate(username) {
        const directResult = await fetchFromGitHubApi(username);
        
        if (directResult.success) {
            return directResult.data;
        }
        
        if (directResult.useProxy) {
            console.log('GitHub Join Date: Use Proxy');
            const proxyResult = await fetchFromProxyApi(username);
            
            if (proxyResult.success) {
                return proxyResult.data;
            }
        }
        
        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 });

})();