AO3 Fic Rater & Tracker

Adds Kudos/Hits ratio, read highlighting (red), custom ratings, and update tracking to AO3.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AO3 Fic Rater & Tracker
// @description  Adds Kudos/Hits ratio, read highlighting (red), custom ratings, and update tracking to AO3.
// @namespace    https://github.com/PreethuPradeep/AO3FicRater
// @author       PeetaMellark
// @version      3.1.0
// @history      3.1.0 - Removed all kudos-tracking features to focus on read history. Fixed variable scope bug (stats_page). Fixed sync pagination bug (now finds all pages).
// @history      3.0.1 - Added extensive console.log messages for debugging all features.
// @history      3.0.0 - Complete rewrite. Added 2-mode sync (History + Kudos). Added manual "Mark as Read" button to enable update tracking.
// @homepageURL  https://github.com/PreethuPradeep/AO3FicRater
// @match        https://archiveofourown.org/*
// @match        https://archiveofourown.gay/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @require      https://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js
// @license MIT
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @connect      archiveofourown.org
// @connect      archiveofourown.gay
// ==/UserScript==

// ~~ SETTINGS ~~ //
var always_count = true;
var always_sort = false;
var hide_hitcount = true;
var highlight_read = true;
var colourbg = true;
var ratio_red = '#ffdede';
var lvl1 = 4;
var ratio_yellow = '#fdf2a3';
var lvl2 = 7;
var ratio_green = '#c4eac3';
var read_highlight_color = 'rgba(255, 0, 0, 0.05)';
// AO3 theme colors
var ao3_bg = '#f8f8f8';
var ao3_text = '#333333';
var ao3_accent = '#900';
var ao3_border = '#d0d0d0';
var ao3_secondary = '#666';
// ~~ END OF SETTINGS ~~ //

// Global storage
var readWorksSet = new Set();
var workMetadata = {};
var syncInProgress = false;

// *** SCOPE FIX ***
// These are now global-like (within the script) so all functions can see them
var countable = false;
var sortable = false;
var stats_page = false;


(function ($) {
    console.log('%c[AO3 RATER]: Script loading...', 'color: #008080; font-weight: bold;');

    Promise.all([
        GM_getValue('alwayscountlocal', 'yes'),
        GM_getValue('alwayssortlocal', 'no'),
        GM_getValue('hidehitcountlocal', 'yes'),
        GM_getValue('highlightreadlocal', 'yes'),
        GM_getValue('ao3_read_works', '[]'),
        // Removed kudosed_works
        GM_getValue('ao3_work_metadata', '{}')
    ]).then(function(values) {

        console.log('[AO3 RATER]: START: All settings and data loaded from GM_storage.');

        always_count = (values[0] == 'yes');
        always_sort = (values[1] == 'yes');
        hide_hitcount = (values[2] == 'yes');
        highlight_read = (values[3] == 'yes');

        try {
            readWorksSet = new Set(JSON.parse(values[4]));
            console.log(`[AO3 RATER]: Loaded ${readWorksSet.size} Read Work IDs.`);
        } catch (e) {
            console.error('[AO3 RATER]: Failed to parse stored read works!', e);
            readWorksSet = new Set();
        }

        try {
            workMetadata = JSON.parse(values[5]); // Index is now 5
            console.log(`[AO3 RATER]: Loaded Metadata for ${Object.keys(workMetadata).length} works.`);
        } catch (e) {
            console.error('[AO3 RATER]: Failed to parse stored work metadata!', e);
            workMetadata = {};
        }

        runMainScript();
    });

    function runMainScript() {
        console.log('[AO3 RATER]: Running main script functions...');
        // *** SCOPE FIX ***
        // The 'var' declarations are removed from here

        checkCountable();
        checkAndStoreHistoryUrl();

        if (always_count) {
            countRatio();
            if (always_sort) {
                sortByRatio();
            }
        }

        displayRatingsAndUpdates();
        setupWorkPageTracking(); // For auto-saving progress when visiting a work page
        console.log('[AO3 RATER]: Main script functions complete.');
    }


    // check if it's a list of works/bookmarks/statistics, or header on work page
    function checkCountable() {
        var found_stats = $('dl.stats');
        if (found_stats.length) {
            if (found_stats.closest('li').is('.work') || found_stats.closest('li').is('.bookmark')) {
                countable = true;
                sortable = true;
                addRatioMenu();
            } else if (found_stats.parents('.statistics').length) {
                countable = true;
                sortable = true;
                stats_page = true; // This is now set correctly in the global scope
                addRatioMenu();
            } else if (found_stats.parents('dl.work').length) {
                countable = true;
                addRatioMenu();
            }
        }
    }

    // --- attach the menu ---
    function addRatioMenu() {
        console.log('[AO3 RATER]: Adding "Ratings & Stats" menu to header.');
        var header_menu = $('ul.primary.navigation.actions');
        var ratio_menu = $('<li class="dropdown"></li>').html('<a>Ratings & Stats</a>');
        header_menu.find('li.search').before(ratio_menu);
        var drop_menu = $('<ul class="menu dropdown-menu"></li>');
        ratio_menu.append(drop_menu);

        // --- Standard Buttons ---
        var button_count = $('<li></li>').html('<a>Count on this page</a>');
        button_count.click(function () { countRatio(); });
        var button_sort = $('<li></li>').html('<a>Sort by kudos/hits ratio</a>');
        button_sort.click(function () { sortByRatio(); });
        var button_sort_rating = $('<li></li>').html('<a>Sort by rating</a>');
        button_sort_rating.click(function () { sortByRating(); });
        var button_settings = $('<li></li>').html('<a style="padding: 0.5em 0.5em 0.25em; text-align: center; font-weight: bold; border-bottom: 1px solid ' + ao3_border + '; display: block; color: ' + ao3_text + ';">Settings</a>');

        drop_menu.append(button_count);
        if (sortable) {
            drop_menu.append(button_sort);
            drop_menu.append(button_sort_rating);
        }
        drop_menu.append(button_settings);

        // --- Toggle Buttons ---
        var button_count_toggle = $(always_count ? '<li class="count-yes"><a>Count automatically: YES</a></li>' : '<li class="count-no"><a>Count automatically: NO</a></li>');
        drop_menu.on('click', 'li.count-yes, li.count-no', function () {
            always_count = !always_count;
            GM_setValue('alwayscountlocal', always_count ? 'yes' : 'no');
            $(this).find('a').text('Count automatically: ' + (always_count ? 'YES' : 'NO'));
            $(this).toggleClass('count-yes count-no');
        });
        drop_menu.append(button_count_toggle);

        var button_sort_toggle = $(always_sort ? '<li class="sort-yes"><a>Sort automatically: YES</a></li>' : '<li class="sort-no"><a>Sort automatically: NO</a></li>');
        drop_menu.on('click', 'li.sort-yes, li.sort-no', function () {
            always_sort = !always_sort;
            GM_setValue('alwayssortlocal', always_sort ? 'yes' : 'no');
            $(this).find('a').text('Sort automatically: ' + (always_sort ? 'YES' : 'NO'));
            $(this).toggleClass('sort-yes sort-no');
        });
        drop_menu.append(button_sort_toggle);

        var button_hide_toggle = $(hide_hitcount ? '<li class="hide-yes"><a>Hide hitcount: YES</a></li>' : '<li class="hide-no"><a>Hide hitcount: NO</a></li>');
        drop_menu.on('click', 'li.hide-yes, li.hide-no', function () {
            hide_hitcount = !hide_hitcount;
            GM_setValue('hidehitcountlocal', hide_hitcount ? 'yes' : 'no');
            $('.stats .hits').css('display', hide_hitcount ? 'none' : '');
            $(this).find('a').text('Hide hitcount: ' + (hide_hitcount ? 'YES' : 'NO'));
            $(this).toggleClass('hide-yes hide-no');
        });
        drop_menu.append(button_hide_toggle);

        var button_highlight_toggle = $(highlight_read ? '<li class="highlight-yes"><a>Highlight read: YES</a></li>' : '<li class="highlight-no"><a>Highlight read: NO</a></li>');
        drop_menu.on('click', 'li.highlight-yes, li.highlight-no', function () {
            highlight_read = !highlight_read;
            GM_setValue('highlightreadlocal', highlight_read ? 'yes' : 'no');
            $(this).find('a').text('Highlight read: ' + (highlight_read ? 'YES' : 'NO'));
            $(this).toggleClass('highlight-yes highlight-no');
            displayRatingsAndUpdates(); // Refresh highlights
        });
        drop_menu.append(button_highlight_toggle);

        // --- Sync Buttons ---
        var button_refresh = $('<li class="refresh-reads"></li>').html('<a style="color: ' + ao3_accent + ';">🔄 Clear All Local Data</a>');
        drop_menu.on('click', 'li.refresh-reads', function () {
            if (confirm('This will delete all your saved ratings and read history from this script. Are you sure?')) {
                console.log('%c[AO3 RATER]: User cleared all local data.', 'color: red; font-weight: bold;');
                GM_deleteValue('ao3_read_works');
                GM_deleteValue('ao3_work_metadata');
                GM_deleteValue('ao3_history_url');
                readWorksSet.clear();
                workMetadata = {};
                alert('All local data cleared. Please refresh the page.');
            }
        });
        drop_menu.append(button_refresh);

        var button_sync_full = $('<li class="full-history-sync"></li>').html('<a style="color: ' + ao3_accent + '; font-weight: bold;">🔄 Sync Full History (Read)</a>');
        drop_menu.on('click', 'li.full-history-sync', function () {
            syncFullHistory(this);
        });
        drop_menu.append(button_sync_full);

        // Removed Kudos Sync Button
    }

    // --- Helper function to fetch a page using GM_xmlhttpRequest ---
    function fetchPage(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                onload: function(response) {
                    if (response.status >= 200 && response.status < 400) {
                        resolve(response.responseText);
                    } else {
                        console.error(`[AO3 RATER]: Failed to fetch page: ${url} (Status: ${response.status})`);
                        reject(new Error('Failed to fetch page: ' + response.status));
                    }
                },
                onerror: function(error) {
                    console.error(`[AO3 RATER]: Network error fetching ${url}:`, error);
                    reject(new Error('Network error: ' + error));
                }
            });
        });
    }

    // --- Your specific scraper for fic blurbs ---
    function scrapeWorkIdsFromHTML(htmlToScrape) {
        var workIds = new Set();
        var $html = $(htmlToScrape);
        $html.find('li.work.blurb h4.heading a, li.bookmark.blurb h4.heading a').each(function() {
            var workLink = $(this).attr('href');
            if (workLink && workLink.indexOf('/works/') !== -1) {
                var workIdMatch = workLink.match(/\/works\/(\d+)/);
                if (workIdMatch && workIdMatch[1]) {
                    var workId = workIdMatch[1];
                    if (/^\d+$/.test(workId)) {
                        workIds.add(workId);
                    }
                }
            }
        });
        return workIds;
    }

    // --- Full History Sync Function ---
    async function syncFullHistory(buttonElement) {
        if (syncInProgress) {
            alert('A sync is already in progress. Please wait.');
            return;
        }
        syncInProgress = true;
        console.log(`%c[SYNC History]: Starting...`, 'color: blue; font-weight: bold;');

        var urlKey = 'ao3_history_url';
        var dataKey = 'ao3_read_works';
        var syncType = 'History';
        var userLink = $('a[href^="/users/"][href*="/readings"]').first();

        try {
            var baseUrl = await GM_getValue(urlKey);

            if (!baseUrl) {
                 if (userLink.length > 0) {
                     baseUrl = userLink.attr('href').split('?')[0];
                     if (baseUrl.indexOf('http') !== 0) {
                         baseUrl = window.location.origin + baseUrl;
                     }
                     await GM_setValue(urlKey, baseUrl);
                 } else {
                     alert(`Could not find your "${syncType}" URL. Please navigate to your "My ${syncType}" page once to teach the script where it is, then try again.`);
                     syncInProgress = false;
                     return;
                 }
            }

            if (baseUrl.indexOf('http') !== 0) {
                baseUrl = window.location.origin + baseUrl;
            }

            console.log(`[SYNC ${syncType}]: Found base URL:`, baseUrl);

            $(buttonElement).find('a').text('Syncing... Fetching page 1...');

            var firstPageHTML = await fetchPage(baseUrl);
            var $firstPage = $(firstPageHTML);

            var dataSet = readWorksSet; // We only care about the read set
            var originalSize = dataSet.size;

            var firstPageIds = scrapeWorkIdsFromHTML(firstPageHTML);
            firstPageIds.forEach(id => dataSet.add(id));

            // --- *** NEW PAGINATION LOGIC *** ---
            var totalPages = 1;
            var $pagination = $firstPage.find('ol.pagination li a');
            if ($pagination.length > 0) {
                $pagination.each(function() {
                    var pageNum = parseInt($(this).text());
                    if (!isNaN(pageNum) && pageNum > totalPages) {
                        totalPages = pageNum; // Find the highest number
                    }
                });
            }
            // --- *** END NEW LOGIC *** ---

            console.log(`[SYNC ${syncType}]: Found ${totalPages} total pages.`);

            alert(`Starting full ${syncType} sync for ${totalPages} pages. This will take several minutes. Please leave this tab open.`);

            for (var page = 2; page <= totalPages; page++) {
                await new Promise(resolve => setTimeout(resolve, 1500)); // 1.5 second throttle

                var statusText = `Syncing page ${page}/${totalPages}...`;
                $(buttonElement).find('a').text(statusText);
                console.log(`[SYNC ${syncType}]: ${statusText}`);

                var pageUrl = baseUrl + '?page=' + page;

                try {
                    var pageHTML = await fetchPage(pageUrl);
                    var pageIds = scrapeWorkIdsFromHTML(pageHTML);
                    pageIds.forEach(id => dataSet.add(id));

                } catch (error) {
                    console.warn(`[SYNC ${syncType}]: Error fetching page ${page}:`, error);
                }
            }

            var newIds = dataSet.size - originalSize;
            await GM_setValue(dataKey, JSON.stringify([...dataSet]));

            $(buttonElement).find('a').text(`🔄 Sync Full ${syncType}`);
            console.log(`%c[SYNC ${syncType}]: COMPLETE. Found ${newIds} new IDs. Total saved: ${dataSet.size}`, 'color: green; font-weight: bold;');
            alert(`Full ${syncType} sync complete! ${dataSet.size} works have been saved. Please refresh the page to see highlights.`);

            displayRatingsAndUpdates(); // Refresh highlights

        } catch (error) {
            console.error(`%c[SYNC ${syncType}]: FAILED:`, 'color: red; font-weight: bold;', error);
            $(buttonElement).find('a').text(`🔄 Sync Full ${syncType}`);
            alert(`Error during ${syncType} sync: ${error.message}. Some works may have been saved. Please try again.`);
        } finally {
            syncInProgress = false;
        }
    }

    // --- On history page, save the URL ---
    function checkAndStoreHistoryUrl() {
        var currentUrl = window.location.href;
        if (currentUrl.indexOf('/users/') !== -1) {
            if (currentUrl.indexOf('/readings') !== -1) {
                console.log('[AO3 RATER]: On History page. Storing URL.');
                GM_setValue('ao3_history_url', currentUrl.split('?')[0]);
            }
            // Removed Kudos check
        }
    }

    // --- Highlight, Track, and Add Buttons ---
    function displayRatingsAndUpdates() {
        console.log('[AO3 RATER]: Running displayRatingsAndUpdates()...');
        var highlightedRead = 0;

        $('li.work.blurb, li.bookmark').each(function() {
            var $work = $(this);
            var workLink = $work.find('h4.heading a').first().attr('href');

            if (!workLink || workLink.indexOf('/works/') === -1) return;
            var workIdMatch = workLink.match(/\/works\/(\d+)/);
            if (!workIdMatch || !workIdMatch[1]) return;

            var workId = workIdMatch[1];
            var metadata = workMetadata[workId] || {};
            var isRead = readWorksSet.has(workId);

            var $stats = $work.find('dl.stats');

            // --- 1. Highlighting Logic ---
            // Clear old highlights first
            $work.css('background-color', '').css('border-left', '').css('margin-left', '').css('padding-left', '');

            if (highlight_read && (isRead || metadata.lastReadChapters !== undefined)) {
                $work.css('background-color', read_highlight_color);
                $work.css('border-left', '3px solid ' + ao3_accent);
                $work.css('margin-left', '-3px');
                $work.css('padding-left', '8px');
                highlightedRead++;
            }
            // Removed 'else if (isKudosed)'

            // --- 2. Inject UI ---
            var $uiContainer = $work.find('.custom-ui-container');
            if ($uiContainer.length === 0) {
                $uiContainer = $('<div class="custom-ui-container" style="margin-top: 5px; display: flex; gap: 10px; align-items: center;"></div>');
                $stats.before($uiContainer);
            }

            var ratingText = metadata.rating !== undefined ? `Rating: ${metadata.rating}/9` : 'Rate 0-9';
            var ratingColor = metadata.rating !== undefined ? getRatingColor(metadata.rating) : ao3_secondary;
            var buttonStyle = 'cursor:pointer; color:' + ao3_accent + '; text-decoration:none; border:1px solid ' + ao3_border + '; padding:2px 6px; border-radius:3px; display:inline-block; font-size:0.9em;';
            var uiHTML = `
                <span class="custom-rate-button" data-work-id="${workId}" style="${buttonStyle} color:${ratingColor};" title="Click to rate this fic">${ratingText}</span>
                <span class="custom-mark-read-button" data-work-id="${workId}" style="${buttonStyle}" title="Mark all current chapters as read">✓ Mark as Read</span>
            `;
            $uiContainer.html(uiHTML);

            // --- 3. Update Notification Logic ---
            var $updateContainer = $work.find('.custom-update-container');

            if (metadata.lastReadChapters !== undefined) {
                if ($updateContainer.length === 0) {
                    $updateContainer = $('<div class="custom-update-container" style="margin-top: 5px;"></div>');
                    $stats.prepend($updateContainer);
                }

                var $chapters = $work.find('dd.chapters');
                var chapterText = $chapters.text().trim();
                var currentChapters = 0;
                var totalChapters = 0;

                if (chapterText) {
                    var chapterMatch = chapterText.match(/(\d+)/);
                    if (chapterMatch) currentChapters = parseInt(chapterMatch[1]);
                    var totalMatch = chapterText.match(/\/(\d+)/);
                    totalChapters = totalMatch ? parseInt(totalMatch[1]) : currentChapters;
                }

                var $updateDate = $work.find('.datetime');
                var updateDate = parseDate($updateDate.attr('title') || $updateDate.text());
                var lastReadDate = parseDate(metadata.lastReadDate);

                var updateMessage = '';

                if (lastReadDate && updateDate && updateDate > lastReadDate && currentChapters > metadata.lastReadChapters) {
                    var unreadChapters = currentChapters - metadata.lastReadChapters;
                    updateMessage += `
                        <dt style="font-weight:bold; color:${ao3_accent};">⚠ Updated:</dt>
                        <dd style="color:${ao3_accent}; font-weight:bold;">+${unreadChapters} new chapter${unreadChapters > 1 ? 's' : ''}</dd>
                    `;
                }

                var chaptersRemaining = totalChapters > 0 ? totalChapters - metadata.lastReadChapters : 0;
                if (chaptersRemaining > 0 && currentChapters > metadata.lastReadChapters) {
                     updateMessage += `
                        <dt style="color:${ao3_secondary};">Chapters left:</dt>
                        <dd style="color:${ao3_secondary};">${chaptersRemaining} / ${totalChapters}</dd>
                    `;
                } else if (chaptersRemaining <= 0 && currentChapters >= metadata.lastReadChapters) {
                     updateMessage += `
                        <dt style="color:${ao3_secondary};">Status:</dt>
                        <dd style="color:green;">Up to date</dd>
                    `;
                }

                if (lastReadDate) {
                     updateMessage += `
                        <dt style="color:${ao3_secondary};">Last read:</dt>
                        <dd style="color:${ao3_secondary};">${formatReadableDate(lastReadDate)}</dd>
                    `;
                }
                $updateContainer.html(updateMessage);
            } else {
                 if ($updateContainer.length > 0) $updateContainer.empty();
            }
        });

        console.log(`[AO3 RATER]: Display complete. Highlighted ${highlightedRead} READ works.`);

        // --- 4. Add Global Click Handlers for UI ---
        $(document).off('click.rater').on('click.rater', '.custom-rate-button', function(e) {
            e.preventDefault();
            e.stopPropagation();
            var workId = $(this).data('work-id');
            console.log(`[AO3 RATER]: User clicked RATE for Work ID: ${workId}`);
            promptRating(workId);
        });

        $(document).off('click.marker').on('click.marker', '.custom-mark-read-button', function(e) {
            e.preventDefault();
            e.stopPropagation();
            var workId = $(this).data('work-id');
            console.log(`%c[AO3 RATER]: User clicked MARK AS READ for Work ID: ${workId}`, 'color: orange;');
            var $work = $(this).closest('li.work.blurb, li.bookmark');
            markAsRead(workId, $work);
        });
    }

    // --- Rating Functions ---
    function promptRating(workId) {
        var currentRating = workMetadata[workId] ? workMetadata[workId].rating : '';
        var rating = prompt('Rate this work (0-9):\n\n0 = Worst\n9 = Best', currentRating);

        if (rating !== null) {
            rating = parseInt(rating);
            if (!isNaN(rating) && rating >= 0 && rating <= 9) {
                if (!workMetadata[workId]) {
                    workMetadata[workId] = {};
                }
                workMetadata[workId].rating = rating;
                console.log(`[AO3 RATER]: Saved rating ${rating} for Work ID: ${workId}`);
                saveMetadata();
                displayRatingsAndUpdates();
            } else if (rating !== '' && rating !== currentRating) {
                alert('Please enter a number between 0 and 9');
            }
        }
    }

    function getRatingColor(rating) {
        if (rating >= 8) return '#5cb85c';
        if (rating >= 6) return '#5bc0de';
        if (rating >= 4) return '#f0ad4e';
        if (rating >= 2) return '#d9534f';
        return '#999999';
    }

    // --- Mark as Read Function ---
    function markAsRead(workId, $work) {
        if (!workMetadata[workId]) {
            workMetadata[workId] = {};
        }

        var $chapters = $work.find('dd.chapters');
        var chapterText = $chapters.text().trim();
        var currentChapters = 0;
        if (chapterText) {
            var chapterMatch = chapterText.match(/(\d+)/);
            if (chapterMatch) {
                currentChapters = parseInt(chapterMatch[1]);
            }
        }

        workMetadata[workId].lastReadDate = new Date().toISOString();
        workMetadata[workId].lastReadChapters = currentChapters;

        console.log(`[AO3 RATER]: Marking Work ID ${workId} as read up to chapter ${currentChapters}.`);

        if (!readWorksSet.has(workId)) {
            readWorksSet.add(workId);
            GM_setValue('ao3_read_works', JSON.stringify([...readWorksSet]));
            console.log(`[AO3 RATER]: Added Work ID ${workId} to main 'read' list.`);
        }

        saveMetadata();
        displayRatingsAndUpdates();
    }

    // --- Utility Functions ---
    function saveMetadata() {
        console.log('[AO3 RATER]: Saving workMetadata to GM_storage...');
        GM_setValue('ao3_work_metadata', JSON.stringify(workMetadata));
    }

    function parseDate(dateStr) {
        if (!dateStr) return null;
        dateStr = dateStr.replace(/Last updated:\s*/i, '');
        var match = dateStr.match(/(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{4})/);
        if (match) {
            return new Date(match[2] + ' ' + match[1] + ', ' + match[3]);
        }
        match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
        if (match) {
            return new Date(dateStr);
        }
        if (dateStr.indexOf('T') > -1) {
             var isoDate = new Date(dateStr);
             if (!isNaN(isoDate.getTime())) return isoDate;
        }
        var date = new Date(dateStr);
        if (!isNaN(date.getTime())) return date;
        return null;
    }

    function formatReadableDate(date) {
        if (!date) return 'Unknown';
        var now = new Date();
        var diffMs = now.getTime() - date.getTime();
        var diffSecs = Math.floor(diffMs / 1000);
        var diffMins = Math.floor(diffSecs / 60);
        var diffHours = Math.floor(diffMins / 60);
        var diffDays = Math.floor(diffHours / 24);

        if (diffSecs < 60) { return 'Just now'; }
        else if (diffMins < 60) { return diffMins + ' min' + (diffMins > 1 ? 's' : '') + ' ago'; }
        else if (diffHours < 24) { return diffHours + ' hour' + (diffHours > 1 ? 's' : '') + ' ago'; }
        else if (diffDays < 365) { return diffDays + ' day' + (diffDays > 1 ? 's' : '') + ' ago'; }
        else {
            var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
            return months[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear();
        }
    }

    // --- Auto-save read progress on a work page ---
    function setupWorkPageTracking() {
        // Removed kudos tracking

        // Auto-save read progress when on a work page
        var currentUrl = window.location.href;
        if (currentUrl.match(/\/works\/(\d+)/)) {
            var workIdMatch = currentUrl.match(/\/works\/(\d+)/);
            if (workIdMatch && workIdMatch[1]) {
                var workIdNum = workIdMatch[1];

                if (!readWorksSet.has(workIdNum)) {
                    readWorksSet.add(workIdNum);
                    GM_setValue('ao3_read_works', JSON.stringify([...readWorksSet]));
                    console.log(`%c[AO3 RATER]: Work page loaded. Added Work ID ${workIdNum} to read list.`, 'color: orange;');
                }

                if (!workMetadata[workIdNum]) {
                    workMetadata[workIdNum] = {};
                }
                workMetadata[workIdNum].lastReadDate = new Date().toISOString();

                var $chapterTitle = $('h3.title').text().match(/Chapter (\d+)/);
                var $chapterDropdown = $('#selected_id option[selected="selected"]');
                var currentChapter = 1;

                if ($chapterTitle && $chapterTitle[1]) {
                    currentChapter = parseInt($chapterTitle[1]);
                } else if ($chapterDropdown.length > 0) {
                    currentChapter = parseInt($chapterDropdown.val());
                } else {
                    var $chapters = $('dd.chapters');
                    var chapterText = $chapters.text().trim();
                    if (chapterText === '1/1') {
                         currentChapter = 1;
                    }
                }

                var oldChapters = workMetadata[workIdNum].lastReadChapters || 0;
                if (currentChapter >= oldChapters) {
                    workMetadata[workIdNum].lastReadChapters = currentChapter;
                    console.log(`%c[AO3 RATER]: Work page loaded. Updated last-read chapter for ${workIdNum} to ${currentChapter}.`, 'color: orange;');
                }

                saveMetadata();
            }
        }
    }

    // --- Functions from the original script ---
    function countRatio() {
        if (countable) {
            $('dl.stats').each(function () {
                var $hits = $(this).find('dd.hits');
                var $kudos = $(this).find('dd.kudos');
                if ($kudos.length && $hits.length && $hits.text() !== '0') {
                    var hits_count = parseInt($hits.text().replace(/\D/g, ''));
                    var kudos_count = parseInt($kudos.text().replace(/\D/g, ''));
                    if (hits_count > 0) {
                        var percents = 100 * kudos_count / hits_count;
                        var percents_print = percents.toFixed(1).replace('.', ',');
                        var ratio_label = $('<dt class="kudoshits"></dt>').text('Kudos/Hits:');
                        var ratio_value = $('<dd class="kudoshits"></dd>').text(percents_print + '%').css('font-weight', 'bold');
                        $hits.after(ratio_label, ratio_value);

                        if (colourbg) {
                            if (percents >= lvl2) {
                                ratio_value.css('background-color', ratio_green);
                            } else if (percents >= lvl1) {
                                ratio_value.css('background-color', ratio_yellow);
                            } else {
                                ratio_value.css('background-color', ratio_red);
                            }
                        }
                        if (hide_hitcount && !stats_page) { // stats_page is now correctly in scope
                            $(this).find('.hits').css('display', 'none');
                        }
                        $(this).closest('li').attr('kudospercent', percents);
                    }
                } else {
                    $(this).closest('li').attr('kudospercent', 0);
                }
            });
        }
    }

    function sortByRatio(ascending) {
        if (sortable) {
            var sortable_lists = $('dl.stats').closest('li').parent();
            sortable_lists.each(function () {
                var list_elements = $(this).children('li');
                list_elements.sort(function (a, b) {
                    return parseFloat(b.getAttribute('kudospercent')) - parseFloat(a.getAttribute('kudospercent'));
                });
                if (ascending) {
                    $(list_elements.get().reverse()).detach().appendTo($(this));
                } else {
                    list_elements.detach().appendTo($(this));
                }
            });
        }
    }

    function sortByRating(ascending) {
        if (sortable) {
            var sortable_lists = $('dl.stats').closest('li').parent();
            sortable_lists.each(function () {
                var list_elements = $(this).children('li');
                list_elements.each(function() {
                    var $element = $(this);
                    var workLink = $element.find('h4.heading a').first().attr('href');
                    if (workLink && workLink.indexOf('/works/') !== -1) {
                        var workIdMatch = workLink.match(/\/works\/(\d+)/);
                        if (workIdMatch && workIdMatch[1]) {
                            var metadata = workMetadata[workIdMatch[1]] || {};
                            var rating = metadata.rating !== undefined ? metadata.rating : -1;
                            $element.attr('custom-rating', rating);
                        }
                    }
                });
                list_elements.sort(function (a, b) {
                    return parseFloat(b.getAttribute('custom-rating')) - parseFloat(a.getAttribute('custom-rating'));
                });
                if (ascending) {
                    $(list_elements.get().reverse()).detach().appendTo($(this));
                } else {
                    list_elements.detach().appendTo($(this));
                }
            });
        }
    }

})(window.jQuery);