Fun little countdown page - track key dates/times

Something simple for post Christmas blues. :smiley:

The following code can be used with UIBUILDER's router as I'm doing in the example images. Or you can easily adapt to be a stand-alone page or incorporate into any page.

It doesn't actually use any UIBUILDER features right now! At some point, I will want to link it to Node-RED though so that items can be sent from and updated to Node-RED via UIBUILDER. But right now, it should work anywhere with a reasonably current browser.

I think this is a decent demonstration of just what can be easily achieved using vanilla HTML, CSS and JavaScript. It took just a few hours to perfect with some useful help from GitHub Copilot (Claude Opus 4.5).

Items are stored in the browser local storage and so are retained. But obviously don't sync anywhere right now.


As you can see, there is some highlighting (very basic) and you can calculate dates/times in the dialogue box.

The display updates every 10 seconds.

countdown.html

<style>
    * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }

    body {
        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
        line-height: 1.6;
        padding: 2rem;
    }

    h1 {
        margin-bottom: 1rem;
    }

    .controls {
        display: flex;
        align-items: center;
        gap: 1.5rem;
        margin-bottom: 1.5rem;
    }

    /* :root {
        --std-gap: 0.5rem;
        --border-radius: 0.25rem;
        color-scheme: light dark;
    } */

    /* Light theme */
    html.light {
        --bg-color: hsl(0, 0%, 100%);
        --text-color: hsl(0, 0%, 10%);
        --text-secondary: hsl(0, 0%, 30%);
        --text-tertiary: hsl(0, 0%, 40%);
        --border-color: hsl(0, 0%, 80%);
        --list-background: hsl(210, 20%, 95%);
        --list-hover: hsl(210, 20%, 92%);
        --dialog-bg: hsl(0, 0%, 100%);
        --dialog-backdrop: hsl(0, 0%, 0%, 0.5);
        --input-bg: hsl(0, 0%, 100%);
        --led-on-color: hsl(120, 100%, 50%);
        --led-on-glow-color: hsl(120, 100%, 70%);
        --led-off-color: hsl(0, 0%, 20%);
        --led-graph-background: hsl(0, 0%, 10%);
        --button-primary-color: hsl(210, 80%, 50%);
        --button-primary-hover: hsl(210, 80%, 45%);
        --button-secondary-color: hsl(0, 0%, 95%);
        --button-secondary-hover: hsl(0, 0%, 90%);
        --elapsed-color: hsl(210, 80%, 40%);
        --past-color: hsl(0, 60%, 50%);
        --result-bg: hsl(120, 30%, 95%);
        --result-border: hsl(120, 30%, 80%);
    }

    /* Dark theme (default) */
    :root,
    html.dark {
        --bg-color: hsl(0, 0%, 12%);
        --text-color: hsl(0, 0%, 90%);
        --text-secondary: hsl(0, 0%, 75%);
        --text-tertiary: hsl(0, 0%, 65%);
        --border-color: hsl(0, 0%, 30%);
        --list-background: hsl(210, 10%, 20%);
        --list-hover: hsl(210, 10%, 25%);
        --dialog-bg: hsl(0, 0%, 15%);
        --dialog-backdrop: hsl(0, 0%, 0%, 0.7);
        --input-bg: hsl(0, 0%, 20%);
        --led-on-color: hsl(120, 100%, 10%);
        --led-on-glow-color: hsl(120, 100%, 30%);
        --led-off-color: hsl(0, 0%, 30%);
        --led-graph-background: hsl(0, 0%, 8%);
        --button-primary-color: hsl(210, 80%, 55%);
        --button-primary-hover: hsl(210, 80%, 60%);
        --button-secondary-color: hsl(0, 0%, 25%);
        --button-secondary-hover: hsl(0, 0%, 30%);
        --elapsed-color: hsl(210, 80%, 60%);
        --past-color: hsl(0, 60%, 60%);
        --result-bg: hsl(120, 20%, 18%);
        --result-border: hsl(120, 30%, 35%);
    }

    /* body {
        background: var(--bg-color);
        color: var(--text-color);
        font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
        line-height: 1.6;
        padding: 2rem;
    } */

    /* h1 {
        margin-bottom: 1rem;
    } */

    .controls {
        display: flex;
        align-items: center;
        gap: 1.5rem;
        margin-bottom: 1.5rem;
    }

    /* #region countdown graph */
    #add-countdown-btn {
        padding: 0.5rem 1rem;
        font-size: 1rem;
        cursor: pointer;
        background: var(--button-primary-color);
        color: white;
        border: 1px solid var(--button-primary-color);
        border-radius: var(--border-radius);
        transition: background 0.2s ease;
    }

    #add-countdown-btn:hover {
        background: var(--button-primary-hover);
    }

    .led-bar {
        display: flex;
        flex-direction: column-reverse;
        gap: 0.125rem;
        padding: 0.25rem;
        background: var(--led-graph-background);
        border-radius: var(--border-radius);
        box-shadow: inset 0 0 0.5rem hsl(0, 0%, 0%, 0.5);
    }
    .led-segment {
        width: 1.5rem;
        height: 0.125rem;
        background: var(--led-off-color);
        border-radius: 0.0625rem;
        transition: background 0.3s ease, box-shadow 0.3s ease;
    }
    .led-segment.on {
        background: var(--led-on-color);
        box-shadow: 
            0 0 0.5rem var(--led-on-color),
            inset 0 0 0.25rem var(--led-on-glow-color);
    }
    /* #endregion countdown graph */

    /* #region Countdown List Styles */
    #countdown-list {
        list-style: none;
        /* margin-top: 1rem; */
        display: grid;
        /* grid-template-columns: auto 1fr auto auto; */
        grid-template-columns: auto auto auto auto;
        /* gap: 0.5rem 1rem; */
        align-items: center;
    }

    #countdown-list li {
        /* Aligns columns accross the whole list */
        display: contents;
        cursor: pointer;
        background-color:var(--elapsed-color);
    }

    #countdown-list li > * {
        background: var(--list-background);
        padding: 0.1rem 0.5rem;
    }

    #countdown-list li > :first-child {
        padding-left: 1rem;
        border-top-left-radius: 0.25rem;
        border-bottom-left-radius: 0.25rem;
    }

    #countdown-list li > :last-child {
        padding-right: 1rem;
        border-top-right-radius: 0.25rem;
        border-bottom-right-radius: 0.25rem;
    }

    #countdown-list input[type="checkbox"] {
        width: 1.25rem;
        height: 1.25rem;
        justify-self: center;
    }

    #countdown-list .description {
        /* color: hsl(0, 0%, 30%); */
        min-width: 10rem;
        max-width: min(40vw, 30rem);
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    #countdown-list .elapsed {
        font-weight: bold;
        color: var(--elapsed-color);
        text-align: right;
        min-width: 8rem;
    }

    #countdown-list .target {
        color: var(--text-tertiary);
        white-space: nowrap;
        max-width: min(30vw, 20rem);
        overflow: hidden;
        text-overflow: ellipsis;
    }

    @media (max-width: 634px) {
        #countdown-list {
            grid-template-columns: 1fr;
            gap: 0;
        }

        #countdown-list li {
            display: grid;
            grid-template-columns: auto 1fr;
            grid-template-rows: auto auto;
            gap: 0.5rem;
            margin-bottom: 0.5rem;
            background: var(--list-background);
            padding: 0.75rem;
            border-radius: 0.25rem;
        }

        #countdown-list li > * {
            background: transparent;
            padding: 0;
        }

        #countdown-list li > :first-child,
        #countdown-list li > :last-child {
            padding: 0;
            border-radius: 0;
        }

        #countdown-list input[type="checkbox"] {
            grid-row: 1 / 3;
            align-self: start;
            margin-top: 0.25rem;
        }

        #countdown-list .description {
            grid-column: 2;
            grid-row: 1;
            min-width: 0;
            max-width: none;
            font-weight: 600;
        }

        #countdown-list .elapsed {
            grid-column: 2;
            grid-row: 2;
            text-align: left;
            min-width: 0;
            font-size: 0.9rem;
        }

        #countdown-list .target {
            grid-column: 2;
            grid-row: 2;
            justify-self: end;
            max-width: none;
            font-size: 0.9rem;
        }
    }

    #countdown-list .description {
        /* color: hsl(0, 0%, 30%); */
        flex: 1;
    }

    #countdown-list .target {
        color: hsl(0, 0%, 40%);
    }

    #countdown-list .past {
        color: var(--past-color);
    }
    /* #endregion Countdown List Styles */

    /* #region Dialog Styles */
    dialog {
        padding: .5rem;
        border: 3px solid var(--border-color);
        border-radius: var(--border-radius);
        max-width: 100%;
        min-width: 10em;
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        margin: 0;
    }

    .main-input {
        display: block;

        #description {
            width: 100%;
        }
        #target-date {
            width: min-content;
        }
        #target-time {
            width: min-content;
        }
        div {
            margin-top: 0.5em;
        }
    }

    .adjustment-section {
        border: 1px solid var(--border-color);
        border-radius: var(--border-radius);
        padding: 0.5rem;
        /* margin: 0.5em 0; */
        min-width: min-content;
        width: 100%;
    }

    .adjustment-grid {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 0.75rem 1rem;

        label {
            display: flex;
            flex-direction: column;
            gap: 0.25rem;
            font-size: 0.9rem;

            input {
                width: 3em;
            }
        }
    }

    .adjustment-row {
        display: flex;
        gap: 0.5rem;
        align-items: center;
        flex-wrap: wrap;
        margin-right: 0.5em;

        label {
            display: flex;
            flex-direction: column;
            gap: 0.25rem;
            text-align: center;
        }

        input[type="radio"] {
            margin-right: 0.25rem;
        }

    }

    .result-section {
        background: var(--input-bg);
        border: 1px solid var(--border-color);
        border-radius: 0.25rem;
        padding: 0.5rem;
        margin-bottom: 0.5rem;

        output {
            display: block;
        }
        .result-datetime {
            font-weight: bold;
        }
        .result-duration {
            font-size: 0.9rem;
            color: hsl(0, 0%, 40%);
        }
    }

    .dialog-buttons {
        display: flex;
        gap: 0.5rem;
        justify-content: flex-end;

        button {
            padding: 0.5rem 1rem;
            font-size: 1rem;
            cursor: pointer;
            border-radius: var(--border-radius);
            border: 1px solid var(--border-color);
        }
        button[type="submit"] {
            background: var(--button-primary-color);
            color: white;
            border-color: var(--border-color);
        }
        button[type="reset"] {
            background: var(--button-secondary-color);
        }

        .dialog-buttons button[type="button"] {
            background: var(--button-secondary-color);
        }
    }
    /* #endregion Dialog Styles */

    /* For accessibility: visually hidden class */
    .visually-hidden {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }
</style>

<h2>Countdown List</h2>
<div class="controls">
    <button id="add-countdown-btn" type="button">Add Countdown</button>
    <div class="led-bar" id="led-bar" aria-label="Update timer">
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
        <div class="led-segment"></div>
    </div>
</div>

<ul id="countdown-list" aria-live="polite"></ul>

<dialog id="countdown-dialog" aria-labelledby="dialog-title">
    <h3 id="dialog-title">Add a new timer</h3>
    <form id="countdown-form" method="dialog">
        <div class="main-input">
            <label for="description" class="visually-hidden">Description:</label>
            <input type="text" id="description" placeholder="Enter description" title="Description" required>
            <div>
                <label for="target-date" class="visually-hidden">Date:</label>
                <input type="date" id="target-date" title="Date" required>
                <label for="target-time" class="visually-hidden">Time:</label>
                <input type="time" id="target-time" title="Time" required>
            </div>
        </div>

        <div class="adjustment-section">
            <div class="adjustment-row">
                <label>
                    <input type="radio" name="adjust-mode" value="add" checked>
                    Add
                </label>
                <label>
                    <input type="radio" name="adjust-mode" value="subtract">
                    Subtract
                </label>
            </div>
            <div class="adjustment-grid">
                <label>
                    Years:
                    <input type="number" id="adjust-years" min="0" value="0">
                </label>
                <label>
                    Weeks:
                    <input type="number" id="adjust-weeks" min="0" value="0">
                </label>
                <label>
                    Days:
                    <input type="number" id="adjust-days" min="0" value="0">
                </label>
                <label>
                    Hours:
                    <input type="number" id="adjust-hours" min="0" max="23" value="0">
                </label>
                <label>
                    Minutes:
                    <input type="number" id="adjust-minutes" min="0" max="59" value="0">
                </label>
                <label>
                    Seconds:
                    <input type="number" id="adjust-seconds" min="0" max="59" value="0">
                </label>
            </div>
        </div>

        <div class="result-section">
            <output class="result-datetime" id="result-datetime" title="Target date/time">--</output>
            <output id="result-duration" title="Duration from now">--</output>
        </div>

        <div class="dialog-buttons">
            <button type="submit">Add</button>
            <button type="reset">Reset</button>
            <button type="button" id="cancel-btn">Cancel</button>
        </div>
    </form>
</dialog>


... continued in next post ...

<script>
    /** @type {HTMLButtonElement} */
    const addBtn = document.getElementById('add-countdown-btn')
    /** @type {HTMLDialogElement} */
    const dialog = document.getElementById('countdown-dialog')
    /** @type {HTMLFormElement} */
    const form = document.getElementById('countdown-form')
    /** @type {HTMLUListElement} */
    const countdownList = document.getElementById('countdown-list')
    /** @type {HTMLButtonElement} */
    const cancelBtn = document.getElementById('cancel-btn')
    /** @type {HTMLButtonElement} */
    const submitBtn = form.querySelector('button[type="submit"]')

    // Edit mode tracking
    let editingEntry = null

    // Input elements
    /** @type {HTMLInputElement} */
    const descriptionInput = document.getElementById('description')
    /** @type {HTMLInputElement} */
    const targetDateInput = document.getElementById('target-date')
    /** @type {HTMLInputElement} */
    const targetTimeInput = document.getElementById('target-time')
    /** @type {HTMLInputElement} */
    const adjustYearsInput = document.getElementById('adjust-years')
    /** @type {HTMLInputElement} */
    const adjustWeeksInput = document.getElementById('adjust-weeks')
    /** @type {HTMLInputElement} */
    const adjustDaysInput = document.getElementById('adjust-days')
    /** @type {HTMLInputElement} */
    const adjustHoursInput = document.getElementById('adjust-hours')
    /** @type {HTMLInputElement} */
    const adjustMinutesInput = document.getElementById('adjust-minutes')
    /** @type {HTMLInputElement} */
    const adjustSecondsInput = document.getElementById('adjust-seconds')

    // Result elements
    /** @type {HTMLSpanElement} */
    const resultDatetime = document.getElementById('result-datetime')
    /** @type {HTMLSpanElement} */
    const resultDuration = document.getElementById('result-duration')

    /** @type {Array<{target: Date, description: string, element: HTMLLIElement, wasPast: boolean}>} */
    const countdowns = []

    /** @type {Array<{target: Date, description: string}>} */
    const removedEntries = []
    const MAX_REMOVED_ENTRIES = 10

    const STORAGE_KEY = 'countdown-list-entries'

    /** Saves all countdown targets to localStorage */
    const saveToStorage = () => {
        const entries = countdowns.map(({ target, description, }) => ({
            target: target.toISOString(),
            description,
        }))
        localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
    }

    /** Restores the last removed entry
        * @returns {boolean} True if an entry was restored
        */
    const restoreLastRemoved = () => {
        if (removedEntries.length === 0) return false
    
        const removed = removedEntries.pop()
        addCountdown(removed.target, removed.description)
        sortCountdowns()
        return true
    }

    /** @type {NodeListOf<HTMLDivElement>} */
    const ledSegments = document.querySelectorAll('.led-segment')
    let ledInterval = null
    let currentSegment = 9
    let dialogUpdateInterval = null

    /** Sets the dialog inputs to their default values (current date and time) */
    const setDefaults = () => {
        const now = new Date()
        descriptionInput.value = ''
        targetDateInput.value = now.toISOString().split('T')[0]
        targetTimeInput.value = now.toTimeString().slice(0, 5)
        adjustYearsInput.value = '0'
        adjustWeeksInput.value = '0'
        adjustDaysInput.value = '0'
        adjustHoursInput.value = '0'
        adjustMinutesInput.value = '0'
        adjustSecondsInput.value = '0'
        document.querySelector('input[name="adjust-mode"][value="add"]').checked = true
        updateResult()
    }

    /** Sets the dialog to edit a specific target date/time
     * @param {Date} target - The target date/time to edit
     * @param {string} description - The description text
     * @param {object} entry - The countdown entry being edited
     */
    const setToTarget = (target, description, entry) => {
        descriptionInput.value = description
        targetDateInput.value = target.toISOString().split('T')[0]
        const hours = String(target.getHours()).padStart(2, '0')
        const minutes = String(target.getMinutes()).padStart(2, '0')
        targetTimeInput.value = `${hours}:${minutes}`
        adjustYearsInput.value = '0'
        adjustWeeksInput.value = '0'
        adjustDaysInput.value = '0'
        adjustHoursInput.value = '0'
        adjustMinutesInput.value = '0'
        adjustSecondsInput.value = '0'
        document.querySelector('input[name="adjust-mode"][value="add"]').checked = true
        editingEntry = entry
        submitBtn.textContent = 'Change'
        updateResult()
    }

    /** Calculates the target date/time from current inputs
     * @returns {Date} The calculated target date
     */
    const calculateTargetDate = () => {
        const dateVal = targetDateInput.value
        const timeVal = targetTimeInput.value

        if (!dateVal || !timeVal) return null

        const target = new Date(`${dateVal}T${timeVal}:00`)

        const mode = document.querySelector('input[name="adjust-mode"]:checked').value
        const multiplier = mode === 'add' ? 1 : -1

        const years = parseInt(adjustYearsInput.value, 10) || 0
        const weeks = parseInt(adjustWeeksInput.value, 10) || 0
        const days = parseInt(adjustDaysInput.value, 10) || 0
        const hours = parseInt(adjustHoursInput.value, 10) || 0
        const minutes = parseInt(adjustMinutesInput.value, 10) || 0
        const seconds = parseInt(adjustSecondsInput.value, 10) || 0

        target.setFullYear(target.getFullYear() + (years * multiplier))
        target.setDate(target.getDate() + (weeks * 7 * multiplier))
        target.setDate(target.getDate() + (days * multiplier))
        target.setHours(target.getHours() + (hours * multiplier))
        target.setMinutes(target.getMinutes() + (minutes * multiplier))
        target.setSeconds(target.getSeconds() + (seconds * multiplier))

        return target
    }

    /** Formats the duration between now and a target date
        * @param {Date} target - The target date
        * @returns {{text: string, isPast: boolean}} Formatted duration and whether it's past
        */
    const formatDuration = (target) => {
        const now = new Date()
        let diff = target.getTime() - now.getTime()
        const isPast = diff < 0

        diff = Math.abs(diff)

        const totalMinutes = Math.floor(diff / (1000 * 60))
        const minutes = totalMinutes % 60
        const hours = Math.floor(diff / (1000 * 60 * 60)) % 24
        const days = Math.floor(diff / (1000 * 60 * 60 * 24)) % 7
        const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7))

        const parts = []
        if (weeks > 0) parts.push(`${weeks}w`)
        if (days > 0) parts.push(`${days}d`)
        if (hours > 0) parts.push(`${hours}h`)
        if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`)

        const text = (isPast ? '-' : '') + parts.join(' ')
        return { text, isPast, }
    }

    /** Formats a date for display
        * @param {Date} date - The date to format
        * @returns {string} Formatted date string
        */
    const formatDateTime = (date) => {
        const year = date.getFullYear()
        const month = String(date.getMonth() + 1).padStart(2, '0')
        const day = String(date.getDate()).padStart(2, '0')
        const hours = String(date.getHours()).padStart(2, '0')
        const minutes = String(date.getMinutes()).padStart(2, '0')
        const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
        const dayOfWeek = dayNames[date.getDay()]
    
        return `${year}-${month}-${day} ${hours}:${minutes} ${dayOfWeek}`
    }

    /** Updates the result section in the dialog */
    const updateResult = () => {
        const target = calculateTargetDate()

        if (!target || isNaN(target.getTime())) {
            resultDatetime.textContent = '--'
            resultDuration.textContent = '--'
            return
        }

        resultDatetime.textContent = formatDateTime(target)
        const duration = formatDuration(target)
        resultDuration.textContent = duration.text
    }

    /** Resets all LED segments to on state */
    const resetLEDs = () => {
        ledSegments.forEach(segment => segment.classList.add('on'))
        currentSegment = 9
    }

    /** Turns off one LED segment from top to bottom */
    const turnOffNextLED = () => {
        if (currentSegment >= 0) {
            ledSegments[currentSegment].classList.remove('on')
            currentSegment--
        }
    }

    /** Starts the LED countdown animation */
    const startLEDCountdown = () => {
        if (ledInterval) clearInterval(ledInterval)
        resetLEDs()
        ledInterval = setInterval(turnOffNextLED, 1000)
    }

    /** Updates all countdown entries in the list */
    const updateAllCountdowns = () => {
        if (window.countdownToggle === false) return
    
        countdowns.forEach(({ target, description, element, wasPast, }, index) => {
            const elapsedSpan = element.querySelector('.elapsed')
            const duration = formatDuration(target)
            elapsedSpan.textContent = duration.text
            elapsedSpan.classList.toggle('past', duration.isPast)
    
            // Check if entry just expired (transitioned from future to past)
            if (!wasPast && duration.isPast) {
                // Update the wasPast flag
                countdowns[index].wasPast = true
    
                // Dispatch custom event
                const event = new CustomEvent('countdownExpired', {
                    detail: {
                        description: description,
                        target: target,
                        targetISO: target.toISOString(),
                        element: element,
                    },
                    bubbles: true,
                })
                document.dispatchEvent(event)
                console.log(`Countdown expired: ${description} at ${formatDateTime(target)}`)
            }
        })
        sortCountdowns()
        startLEDCountdown()
    }

    /** Sorts countdown entries by target datetime (earliest first) and reorders in DOM */
    const sortCountdowns = () => {
        // Sort by target time in milliseconds (earliest datetime first)
        countdowns.sort((a, b) => {
            return a.target.getTime() - b.target.getTime()
        })
    
        // Clear and rebuild the list in sorted order
        countdownList.innerHTML = ''
        countdowns.forEach(({ element, }) => {
            countdownList.appendChild(element)
        })
    }

    /** Adds a new countdown entry to the list
        * @param {Date} target - The target date/time
        * @param {string} description - The description text
        */
    const addCountdown = (target, description) => {
        const li = document.createElement('li')
        // const duration = formatDuration(target)

        // li.innerHTML = `
        //     <input type="checkbox" aria-label="Remove countdown">
        //     <div class="content">
        //         <span class="elapsed ${duration.isPast ? 'past' : ''}">${duration.text}</span>
        //         <span class="description">${description}</span>
        //         <span class="target">${formatDateTime(target)}</span>
        //     </div>
        // `
        li.innerHTML = liTemplate(description, target)

        // Add remove functionality to checkbox
        const checkbox = li.querySelector('input[type="checkbox"]')
        checkbox.addEventListener('change', () => {
            if (checkbox.checked) {
                const index = countdowns.findIndex(item => item.element === li)
                if (index > -1) {
                    // Save to removed entries for undo
                    removedEntries.push({
                        target: countdowns[index].target,
                        description: countdowns[index].description,
                    })
                    // Limit removed entries to MAX_REMOVED_ENTRIES
                    if (removedEntries.length > MAX_REMOVED_ENTRIES) {
                        removedEntries.shift()
                    }
    
                    countdowns.splice(index, 1)
                    saveToStorage()
                }
                li.remove()
            }
        })

        // Add click handler to reopen dialog with this target
        const clickableElements = [li.querySelector('.description'), li.querySelector('.elapsed'), li.querySelector('.target')]
        clickableElements.forEach((element) => {
            element.addEventListener('click', () => {
                // Find the actual entry in countdowns array
                const entry = countdowns.find(item => item.element === li)
                if (entry) {
                    setToTarget(entry.target, entry.description, entry)
                    dialog.showModal()
                    // Start live updates in dialog
                    if (dialogUpdateInterval) clearInterval(dialogUpdateInterval)
                    dialogUpdateInterval = setInterval(updateResult, 1000)
                }
            })
        })

        countdownList.appendChild(li)
        const duration = formatDuration(target)
        countdowns.push({ target, description, element: li, wasPast: duration.isPast, })
        saveToStorage()
    }

    /** Loads countdown entries from localStorage */
    const loadFromStorage = () => {
        try {
            const stored = localStorage.getItem(STORAGE_KEY)
            if (!stored) return

            const entries = JSON.parse(stored)
            entries.forEach((entry) => {
                // Handle both old format (string) and new format (object)
                if (typeof entry === 'string') {
                    const target = new Date(entry)
                    if (!isNaN(target.getTime())) {
                        addCountdown(target, 'No description')
                    }
                } else if (entry && entry.target) {
                    const target = new Date(entry.target)
                    if (!isNaN(target.getTime())) {
                        addCountdown(target, entry.description || 'No description')
                    }
                }
            })

            // Don't double-save after loading
            countdowns.forEach(({ target, }) => target)
        } catch (error) {
            console.error('Failed to load countdowns from storage:', error)
        }
        sortCountdowns()
    }

    const liTemplate = (description, target) => {
        const duration = formatDuration(target)
        return `
            <input type="checkbox" aria-label="Remove countdown" title="Remove countdown">
            <span class="description" title="Description">${description}</span>
            <span class="elapsed ${duration.isPast ? 'past' : ''}" title="Elapsed time">${duration.text}</span>
            <span class="target" title="Target date and time">${formatDateTime(target)}</span>
        `
    }
    // Event listeners
    addBtn.addEventListener('click', () => { // Button click to add new countdown
        editingEntry = null
        submitBtn.textContent = 'Add'
        setDefaults()
        dialog.showModal()
        // Start live updates in dialog
        if (dialogUpdateInterval) clearInterval(dialogUpdateInterval)
        dialogUpdateInterval = setInterval(updateResult, 1000)
    })
    cancelBtn.addEventListener('click', () => {
        if (dialogUpdateInterval) {
            clearInterval(dialogUpdateInterval)
            dialogUpdateInterval = null
        }
        dialog.close()
    })

    form.addEventListener('reset', (e) => {
        e.preventDefault()
        setDefaults()
    })
    form.addEventListener('submit', (e) => {
        e.preventDefault()
        const target = calculateTargetDate()
        const description = descriptionInput.value.trim()
        if (target && !isNaN(target.getTime()) && description) {
            if (editingEntry) {
                // Update existing entry
                const index = countdowns.findIndex(item => item === editingEntry)
                if (index > -1) {
                    // Update the entry data
                    countdowns[index].target = target
                    countdowns[index].description = description
    
                    // Update the list item HTML
                    const li = countdowns[index].element
                    // const duration = formatDuration(target)
                    // const contentDiv = li.querySelector('.content')
                    // contentDiv.innerHTML = `
                    //     <span class="elapsed ${duration.isPast ? 'past' : ''}">${duration.text}</span>
                    //     <span class="description">${description}</span>
                    //     <span class="target">${formatDateTime(target)}</span>
                    // `
                    li.innerHTML = liTemplate(description, target)
    
                    saveToStorage()
                    sortCountdowns()
                }
                editingEntry = null
            } else {
                // Add new entry
                addCountdown(target, description)
                sortCountdowns()
            }
    
            if (dialogUpdateInterval) {
                clearInterval(dialogUpdateInterval)
                dialogUpdateInterval = null
            }
            dialog.close()
        }
    })

    // Update result when any input changes
    const inputs = [
        targetDateInput,
        targetTimeInput,
        adjustYearsInput,
        adjustWeeksInput,
        adjustDaysInput,
        adjustHoursInput,
        adjustMinutesInput,
        adjustSecondsInput,
    ]
    inputs.forEach(input => input.addEventListener('input', updateResult))
    document.querySelectorAll('input[name="adjust-mode"]').forEach((radio) => {
        radio.addEventListener('change', updateResult)
    })

    // Initialize LEDs
    startLEDCountdown()

    // Load stored countdowns
    loadFromStorage()
    updateAllCountdowns()

    // Update countdown list every 10 seconds
    const mainUpdateInterval = setInterval(updateAllCountdowns, 10000)

    // Debug controls - exposed to console
    window.countdownToggle = true

    // Handle Ctrl-Z for undo
    document.addEventListener('keydown', (e) => {
        if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
            // Don't trigger if user is typing in an input
            if (document.activeElement.tagName === 'INPUT'
                || document.activeElement.tagName === 'TEXTAREA') {
                return
            }
    
            e.preventDefault()
            if (restoreLastRemoved()) {
                console.log('Restored last removed entry')
            }
        }
    })
</script>

Which one is your retirement date :wink:

1 Like

:smiley:

image

That and a few hospital appointments were the prompt for me doing this.

3 Likes