ChatGPT bring back date grouping

Brings back the date grouping on chatgpt.com

// ==UserScript==
// @name        ChatGPT bring back date grouping
// @version     1.2
// @author      tiramifue
// @description Brings back the date grouping on chatgpt.com
// @match       https://chatgpt.com/*
// @run-at      document-end
// @namespace   https://greasyfork.org/users/570213
// @license     Apache-2.0
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @noframes
// ==/UserScript==

// updated 2025-06-13

(function () {
    'use strict';

    let groupBy = GM_getValue('groupBy', 'updated');

    GM_addStyle(`
.__chat-group-header {
    font-weight: normal;
    padding: 6px 10px;
    font-size: 0.85rem;
    color: #999;
    margin-top: 0;
}

.__chat-group-header:not(:first-of-type) {
    margin-top: 12px;
}
    `)

    function getDateGroupLabel(isoString) {
        const date = new Date(isoString);
        const now = new Date();
        const msInDay = 24 * 60 * 60 * 1000;
        const daysAgo = Math.floor((now - date) / msInDay);
        const monthsAgo = (now.getFullYear() - date.getFullYear()) * 12 + (now.getMonth() - date.getMonth());

        if (daysAgo === 0) return 'Today';
        if (daysAgo === 1) return 'Yesterday';
        if (daysAgo <= 6) return `${daysAgo} days ago`;
        if (daysAgo <= 13) return 'Last week';
        if (daysAgo <= 20) return '2 weeks ago';
        if (daysAgo <= 31) return 'Last month';
        if (monthsAgo <= 11) return `${monthsAgo} months ago`;
        return 'Last year';
    }

    function getReactFiber(dom) {
        for (const key in dom) {
            if (key.startsWith('__reactFiber$')) return dom[key];
        }
        return null;
    }

    function extractChatInfo(fiber) {
        const c = fiber.memoizedProps?.conversation;
        return c
            ? {
            id: c.id,
            title: c.title,
            created: c.create_time,
            updated: c.update_time,
            node: fiber.stateNode
        }
        : null;
    }

    const seenIds = new Set();
    const chatList = [];

    function processNewChatNode(node) {
        const fiber = getReactFiber(node);
        if (!fiber) return;

        let current = fiber;
        while (current && !current.memoizedProps?.conversation) {
            current = current.return;
        }

        if (!current || !current.memoizedProps?.conversation) return;

        const chat = extractChatInfo(current);
        if (chat && !seenIds.has(chat.id)) {
            seenIds.add(chat.id);
            const dateKey = chat[groupBy];
            chat.node = node;
            chatList.push(chat);

            queueRender();
        }
    }

    function groupChatsByGroupName() {
        const groups = new Map();

        for (const chat of chatList) {
            chat.group = getDateGroupLabel(chat[groupBy]);
            if (!groups.has(chat.group)) groups.set(chat.group, []);
            groups.get(chat.group).push(chat);
        }

        return [...groups.entries()].sort((a, b) => {
            const aTime = new Date(a[1][0][groupBy]).getTime();
            const bTime = new Date(b[1][0][groupBy]).getTime();
            return bTime - aTime;
        });
    }

    function clearGroupedChats(aside) {
        aside.querySelectorAll('a[href^="/c/"], .__chat-group-header').forEach(el => el.remove());
    }

    function renderGroupedChats(aside) {
        const observer = aside.__chatObserver;
        if (observer) observer.disconnect();

        clearGroupedChats(aside);
        const groups = groupChatsByGroupName();

        for (const [label, chats] of groups) {
            const header = document.createElement('div');
            header.className = '__chat-group-header';
            header.textContent = label;
            aside.appendChild(header);

            chats
                .sort(sortChats)
                .forEach(chat => aside.appendChild(chat.node));
        }

        if (observer) observer.observe(aside, { childList: true, subtree: true });
    }

    function sortChats(a, b) {
        return new Date(b[groupBy]) - new Date(a[groupBy]);
    }

    let renderTimer = null;

    function queueRender() {
        if (renderTimer) clearTimeout(renderTimer);
        renderTimer = setTimeout(() => {
            const aside = document.querySelector('#history aside');
            if (aside) renderGroupedChats(aside);
        }, 200);
    }

    function observeChatList(aside) {
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === 1 && node.matches('a[href^="/c/"]')) {
                        processNewChatNode(node);
                    }
                }
                for (const node of mutation.removedNodes) {
                    if (node.nodeType === 1 && node.matches('a[href^="/c/"]')) {
                        const index = chatList.findIndex(c => c.node === node);
                        if (index !== -1) {
                            const removed = chatList.splice(index, 1)[0];
                            seenIds.delete(removed.id);
                            queueRender();
                        }
                    }
                }
            }
        });

        observer.observe(aside, { childList: true, subtree: true });
        aside.__chatObserver = observer;
        aside.querySelectorAll('a[href^="/c/"]').forEach(processNewChatNode);
    }

    function insertToggleButton(aside) {
        const header = aside.querySelector('h2');
        if (!header || header.querySelector('.__group-toggle')) return;

        // Wrap h2 content to align flexibly
        const wrapper = document.createElement('div');
        wrapper.style.cssText = `
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding-right: 10px;
    `;

        // Move existing h2 text/content into the wrapper
        while (header.firstChild) {
            wrapper.appendChild(header.firstChild);
        }
        header.appendChild(wrapper);

        const btn = document.createElement('button');
        btn.className = '__group-toggle';
        const icon = '⇅';
        btn.textContent = `${icon} By ${groupBy}`;
        btn.title = 'Click to toggle sorting mode';
        btn.style.cssText = `
        font-size: 0.75rem;
        background-color: #2a2b32;
        border: 1px solid #444;
        border-radius: 999px;
        padding: 3px 10px;
        color: #ccc;
        cursor: pointer;
        transition: background-color 0.2s;
    `;

        btn.addEventListener('mouseenter', () => {
            btn.style.backgroundColor = '#3a3b42';
        });

        btn.addEventListener('mouseleave', () => {
            btn.style.backgroundColor = '#2a2b32';
        });

        btn.addEventListener('click', () => {
            groupBy = groupBy === 'updated' ? 'created' : 'updated';
            GM_setValue('groupBy', groupBy);
            btn.textContent = `${icon} By ${groupBy}`;
            queueRender();
        });

        wrapper.appendChild(btn);
    }

    (function watchSidebar() {
        let lastAside = null;

        function setup(aside) {
            if (!aside || aside === lastAside) return;
            lastAside = aside;

            insertToggleButton(aside);
            observeChatList(aside);
            renderGroupedChats(aside);
            console.log("ChatGPT grouping: sidebar attached.");
        }

        const rootObserver = new MutationObserver(() => {
            const history = document.querySelector('#history');
            if (!history) return;

            const aside = history.querySelector('aside');
            if (aside && aside !== lastAside) {
                setup(aside);
            }
        });

        rootObserver.observe(document.body, { childList: true, subtree: true });

        const asideNow = document.querySelector('#history aside');
        if (asideNow) setup(asideNow);
    })();
})();