ChatGPT bring back date grouping

Brings back the date grouping on chatgpt.com

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        ChatGPT bring back date grouping
// @version     2.1
// @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-09-30

(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;
}

.__chat-date-label {
    position: absolute;
    top: 0;
    right: 12px;
    font-size: 0.7rem;
    color: #888;
    pointer-events: none;
    background-color: transparent;
    line-height: 1;
    text-shadow: 0 0 2px rgba(0,0,0,0.5);
}

.__chat-timestamp {
    position: absolute;
    right: 8px;
    top: -1px;
    font-size: 0.7rem;
    color: #999;
    pointer-events: none;
}
.__hide-timestamps .__chat-timestamp {
    display: none;
}

.__timestamp-icon {
    position: relative;
    display: inline-block;
    opacity: 1;
    transition: opacity 0.2s;
}

.__timestamp-icon.__disabled {
    opacity: 0.5;
}

.__timestamp-icon.__disabled::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 110%;
    height: 0;
    border-top: 2px solid #fff;
    transform: translate(-50%, -50%) rotate(-45deg);
    transform-origin: center;
    pointer-events: none;
}
    `)

    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(chats) {
        chats.querySelectorAll('a[href^="/c/"], .__chat-group-header').forEach(el => el.remove());
    }

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

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

        for (const [label, groupedChats] of groups) {
            const currentGroupBy = groupBy;

            const header = document.createElement('div');
            header.className = '__chat-group-header';
            header.textContent = label;
            chats.appendChild(header);

            groupedChats
                .sort((a, b) => new Date(b[currentGroupBy]) - new Date(a[currentGroupBy]))
                .forEach(chat => {
                const existingLabel = chat.node.querySelector('.__chat-timestamp');
                if (existingLabel) existingLabel.remove();

                const timestamp = document.createElement('div');
                timestamp.className = '__chat-timestamp';
                timestamp.textContent = new Date(chat[currentGroupBy]).toLocaleDateString(undefined, {
                    year: 'numeric', month: 'short', day: 'numeric'
                });

                chat.node.style.position = 'relative';
                chat.node.appendChild(timestamp);

                chats.appendChild(chat.node);
            });

        }


        if (observer) observer.observe(chats, { 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 chats = document.querySelector('#history');
            if (chats) renderGroupedChats(chats);
        }, 200);
    }

    function observeChatList(chats) {
        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(chats, { childList: true, subtree: true });
        chats.__chatObserver = observer;
        chats.querySelectorAll('a[href^="/c/"]').forEach(processNewChatNode);
    }

    function insertToggleButton(chats) {
        const header = chats.parentNode.firstElementChild;
        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;
            width: 100%;
    `;

        // 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', e => {
            e.stopPropagation();
            groupBy = groupBy === 'updated' ? 'created' : 'updated';
            GM_setValue('groupBy', groupBy);
            btn.textContent = `${icon} By ${groupBy}`;
            queueRender();
        });

        const timestampBtn = document.createElement('button');
        timestampBtn.className = '__toggle-timestamps';
        timestampBtn.style.cssText = `
    display: flex;
    align-items: center;
    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;
`;

        const timestampIcon = document.createElement('span');
        timestampIcon.className = '__timestamp-icon';
        timestampIcon.textContent = '🕒';

        function updateTimestampIcon(state) {
            timestampIcon.classList.toggle('__disabled', !state);
        }
        updateTimestampIcon(GM_getValue('showTimestamps', true));


        timestampBtn.appendChild(timestampIcon);
        timestampBtn.title = 'Toggle timestamps';

        timestampBtn.addEventListener('mouseenter', () => {
            timestampBtn.style.backgroundColor = '#3a3b42';
        });
        timestampBtn.addEventListener('mouseleave', () => {
            timestampBtn.style.backgroundColor = '#2a2b32';
        });

        timestampBtn.addEventListener('click', e => {
            e.stopPropagation();
            const current = GM_getValue('showTimestamps', true);
            const next = !current;
            GM_setValue('showTimestamps', next);
            updateTimestampIcon(next);
            const chats = document.querySelector('#history');
            if (chats) chats.classList.toggle('__hide-timestamps', !next);
        });

        const buttonGroup = document.createElement('div');
        buttonGroup.style.cssText = `
  display: flex;
  gap: 6px;
  margin-left: auto;
`;

        buttonGroup.appendChild(btn);
        buttonGroup.appendChild(timestampBtn);
        wrapper.appendChild(buttonGroup);


    }

    (function watchSidebar() {
        let lastChats = null;

        function setup(chats) {
            if (!chats || chats === lastChats) return;
            lastChats = chats;

            chats.classList.toggle('__hide-timestamps', !GM_getValue('showTimestamps', true));

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

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

            if (chats && chats !== lastChats) {
                setup(chats);
            }
        });

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

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

    (function disableCollapse() {
        const observer = new MutationObserver(() => {
            const chatsHeader = document.querySelector('#history').parentNode.firstChild;

            if (chatsHeader && !chatsHeader.__noCollapse) {
                chatsHeader.__noCollapse = true;
                chatsHeader.addEventListener('click', e => {
                    if (e.target.closest('.__group-toggle, .__toggle-timestamps')) return;
                    e.stopImmediatePropagation(); // block React’s collapse toggle
                }, true);
                chatsHeader.style.cursor = 'default';
                chatsHeader.querySelectorAll('.__group-toggle, .__toggle-timestamps')
                    .forEach(btn => { btn.style.cursor = 'pointer'; });
                chatsHeader.querySelector('svg').remove();
                console.log('ChatGPT grouping: collapse disabled.');
            }
        });

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