{"version":3,"file":"product-detail-countdown-timer-element-section.min.js","mappings":"yBAiJA,SAASA,IACP,MAAMC,EAAQ,IAAIC,YAAsC,aAAc,CACpEC,OAAQ,CACNC,UAAWC,KAAKC,SAGpBC,cAAcN,EAChB,CChJA,MAAMO,oCAAoCC,YACxC,6BAAkBC,GAChB,MAAO,CACL,eACA,gBACA,kBAEJ,CA0BQC,iBAAmBC,KAAKC,YAAYC,KAAKF,MAE1C,iBAAAG,GACLH,KAAKI,uBAELC,iBAAiB,aAAcL,KAAKD,kBD+EtCO,WAAWlB,EAAM,GAEZmB,OAAOC,mCACVD,OAAOC,kCAAmC,EAC1CC,YAAYrB,EAAM,MCjFlBY,KAAKU,QACP,CAEO,oBAAAC,GACLC,oBAAoB,aAAcZ,KAAKD,iBACzC,CAEO,wBAAAc,CAAyBC,EAAcC,EAAkBC,GAG9D,OAFAhB,KAAKI,uBAEGU,GACN,IAAK,kBACHd,KAAKiB,UAAYD,EAAoC,IAAzBE,SAASF,EAAU,SAAaG,EAC5D,MACF,IAAK,gBACHnB,KAAKoB,QAAUJ,EAAoC,IAAzBE,SAASF,EAAU,SAAaG,EAI1DnB,KAAKqB,aAAeN,IAAaC,GACnChB,KAAKU,QAET,CAEQ,oBAAAN,GACDJ,KAAKsB,aACRtB,KAAKuB,aAAa,CAAEC,KAAM,SAC1BxB,KAAKsB,WAAWG,UAAY,ouBAEhC,CAEQ,WAAAxB,CAAYZ,GAClB,MAAMqC,EAAkB1B,KAAKsB,WAAWK,cAAc,KAChDC,EAAuB5B,KAAKsB,WAAWK,cAAc,mBACrDE,EAAuB7B,KAAKsB,WAAWK,cAAc,mBAO3D,IAAKD,EACH,OAGF,IAAKE,EACH,OAGF,IAAKC,EACH,OAGF,MAAMC,EDvCH,SAAyBC,EAA0BC,GACxD,GAAID,EAAmBC,IAAiBA,EACtC,OAAO,KAGT,MAAMC,EAAcC,KAAKC,OAAOH,EAAeD,GAAoB,KAC7DK,EAAUH,EAAc,GAExBI,GAAeJ,EAAcG,GAAW,GACxCE,EAAUD,EAAc,GAExBE,GAAaF,EAAcC,GAAW,GACtCE,EAAQD,EAAY,GAEpBE,GAAQF,EAAYC,GAAS,GAE7BE,EAAO,CACXD,EAAKE,WACI,IAATF,EAAa,MAAQ,OACrBD,EAAMG,WACI,IAAVH,EAAc,OAAS,QACvBF,EAAQK,WACI,IAAZL,EAAgB,SAAW,UAC3BF,EAAQO,WACI,IAAZP,EAAgB,SAAW,WAC3BQ,KAAK,KAUP,MAR2B,CACzBH,OACAD,QACAF,UACAF,UACAM,OAIJ,CCGqBG,CAAgBxD,EAAME,OAAOC,UAAWQ,KAAKoB,SAC9D,IAAKU,GAAY9B,KAAKiB,UAAY5B,EAAME,OAAOC,UAE7C,YADAkC,EAAgBoB,UAAUC,OAAO,UAI9BrB,EAAgBoB,UAAUE,SAAS,WACtCtB,EAAgBoB,UAAUG,IAAI,UAGV,IAAlBnB,EAASW,KACXb,EAAqBsB,YAAc,GACR,IAAlBpB,EAASW,KAClBb,EAAqBsB,YAAc,QAEnCtB,EAAqBsB,YAAc,CAACpB,EAASW,KAAKE,WAAY,QAAQC,KAAK,KAG7E,MAGMO,EAAY,CAHJrB,EAASU,MAAMG,WAAWS,SAAS,EAAG,KAG1B,IAFVtB,EAASQ,QAAQK,WAAWS,SAAS,EAAG,KAEhB,IADxBtB,EAASM,QAAQO,WAAWS,SAAS,EAAG,MACFR,KAAK,IAE3Df,EAAqBqB,YAAcC,CACrC,CAEQ,MAAAzC,GACUV,KAAKsB,WAAWK,cAAc,kBACtCuB,YAAclD,KAAKqD,QAAQC,OACrC,EASGC,eAAeC,IAAI,mCACtBD,eAAeE,OAAO,iCAAkC7D","sources":["webpack://lange/./lib/countdown-timer.ts","webpack://lange/./sections/product-detail-countdown-timer-element/product-detail-countdown-timer/index.ts"],"sourcesContent":["// We have now more than one element that displays a countdown clock on the page. When the elements\n// use separate `setInterval()` calls to display their time the effect of them not being\n// synchronized can be seen even if the interval is low (50-100ms).\n//\n// This module provides a very simple synchronization method and some shared utilities for timestamp\n// calculations.\n//\n// Clock synchronization is based on a single `setInterval()` call. This starts a timer which emits\n// `timer-tick` events. Each page element can listen to the events to update the displayed time at\n// the very same moment other elements do.\n//\n// The timer has to be initialized with the `startTimer()` function. There is no possibility to stop\n// the timer, as we would have to implement a reference counter which would be prone to problems,\n// i.e. a bug in a simple component could stop all timers on the page. Instead of stopping the timer\n// the components should stop listening to the events.\n\ndeclare global {\n  /**\n   * Sentinel marking that the shared tick interval has been started, so repeated `startTimer()`\n   * calls do not each spin up their own interval.\n   *\n   * This lives on the global scope rather than in a module variable on purpose. Each section is its\n   * own webpack entry and we do not share chunks, so this file is copied into every bundle that\n   * imports it. A module variable would therefore be per-bundle, letting every section on the page\n   * start its own interval and defeating the synchronization. The tick events are already\n   * dispatched globally, so the guard has to be global too.\n   */\n  var countdown_timer_interval_started: boolean | undefined;\n}\n\n/**\n * The remaining time between two timestamps, broken down into whole units plus a human readable\n * summary. Each numeric component is the remainder within its larger unit, so `hours`, `minutes`,\n * and `seconds` never exceed their natural maximums (e.g. `seconds` is always 0-59).\n */\ninterface TimeLeft {\n  /** Whole days remaining. */\n  days?: number;\n\n  /** Whole hours remaining within the current day (0-23). */\n  hours?: number;\n\n  /** Whole minutes remaining within the current hour (0-59). */\n  minutes?: number;\n\n  /** Whole seconds remaining within the current minute (0-59). */\n  seconds?: number;\n\n  /** Human readable summary of the remaining time, e.g. `\"1 day 2 hours 3 minutes 4 seconds\"`. */\n  text?: string;\n}\n\n/**\n * Calculate the remaining time between currentTimestamp and endTimestamp and express it as an\n * object with number of days, hours, minutes, seconds as integers and a human readable `text`\n *\n * Timestamps should be in milliseconds.\n *\n * This is not guaranteed to return an object. It may return null if the current time is past the\n * end time or if the end time is falsy. In other words, a null return value indicates that either\n * there is no time left or it is not possible to compute it.\n */\nexport function computeTimeLeft(currentTimestamp: number, endTimestamp: number) {\n  if (currentTimestamp > endTimestamp || !endTimestamp) {\n    return null;\n  }\n\n  const secondsLeft = Math.round((endTimestamp - currentTimestamp) / 1000);\n  const seconds = secondsLeft % 60;\n\n  const minutesLeft = (secondsLeft - seconds) / 60;\n  const minutes = minutesLeft % 60;\n\n  const hoursLeft = (minutesLeft - minutes) / 60;\n  const hours = hoursLeft % 24;\n\n  const days = (hoursLeft - hours) / 24;\n\n  const text = [\n    days.toString(),\n    days === 1 ? 'day' : 'days',\n    hours.toString(),\n    hours === 1 ? 'hour' : 'hours',\n    minutes.toString(),\n    minutes === 1 ? 'minute' : 'minutes',\n    seconds.toString(),\n    seconds === 1 ? 'second' : 'seconds'\n  ].join(' ');\n\n  const timeLeft: TimeLeft = {\n    days,\n    hours,\n    minutes,\n    seconds,\n    text\n  };\n\n  return timeLeft;\n}\n\n/**\n * Start the shared heartbeat that drives every countdown on the page.\n *\n * Once started, a tick event fires on the global scope once a second carrying the current\n * timestamp. Each countdown section listens for that event and repaints from the same timestamp, so\n * every countdown on the page advances at the exact same instant instead of drifting out of step\n * with each other.\n *\n * Any section that wants a countdown may call this, in any order, as many times as it likes. Only\n * the very first call anywhere on the page actually creates the interval. Every later call finds\n * the guard already set and skips that part. The guard lives on the global scope rather than in a\n * module variable on purpose. This file is copied into each webpack bundle because we do not share\n * chunks, so a module variable would be per bundle and would let every section start its own\n * interval, which would bring back the drift this module exists to prevent. The sentinel\n * declaration near the top of the file explains that reasoning in full.\n *\n * The immediate tick deliberately sits outside the guard so it runs on every call rather than only\n * the first. Some sections paint their starting value themselves when they connect, but others set\n * their displayed time only inside their tick listener. A section of that second kind that connects\n * after the interval is already running would otherwise show an empty clock for up to a second,\n * until the next regular beat arrives. Firing one immediate tick per caller hands each one a value\n * straight away. The extra repaints this causes are cheap because a section only touches the DOM\n * when its displayed value actually changed.\n */\nexport function startTimer() {\n  setTimeout(tick, 0);\n\n  if (!window.countdown_timer_interval_started) {\n    window.countdown_timer_interval_started = true;\n    setInterval(tick, 1000);\n  }\n}\n\n/**\n * Emit a single tick event stamped with the current time.\n *\n * This is the callback for both the one-shot immediate timeout and the recurring interval that\n * startTimer sets up. The order in which those two fire never matters. Each event carries its own\n * timestamp, and every listener recomputes its whole display from whatever timestamp it receives,\n * holding no state from one tick to the next. So a tick that arrives out of order, or an extra tick\n * from a second caller, at worst causes one redundant repaint, which a section ignores when its\n * displayed value has not actually changed. That is also why there is no timer id to capture or\n * clear here. The immediate timeout has already elapsed by the time this runs, and the interval is\n * meant to run for the life of the page.\n */\nfunction tick() {\n  const event = new CustomEvent<TimerTickEvent['detail']>('timer-tick', {\n    detail: {\n      timestamp: Date.now()\n    }\n  });\n  dispatchEvent(event);\n}\n\n/**\n * This event is fired per tick of the countdown timer. It represents one tick. It captures the\n * precise point in time at which the tick occurred.\n *\n * Events have a native timeStamp property, which at first glance suggests this does not need a\n * custom property as a simple Event would suffice. However, event.timeStamp is not an epoch, it is\n * a high resolution timestamp relative to the page load. Listeners typically compare this event's\n * timestamp to Date.now(). In order to do that comparison using the native event.timeStamp, they\n * have to use performance.timeOrigin + event.timeStamp. We have seen the performance global have\n * issues, the comparison involves more boilerplate, and there is a mental tax on using it because\n * of its subtleties and misleading name. Not to mention the fact that most people write \"timestamp\"\n * in all lowercase but some camel case fanatic thought better. Using a custom property actually\n * reduces the complexity despite its reinvention of the wheel.\n *\n * The name \"timestamp\" conventionally refers to an ISO string. However, given that the native APIs\n * have adopted the term (e.g. the DOM High Resolution Time API), we are stuck with the bad name in\n * the browser setting, and must live with it.\n */\ntype TimerTickEvent = CustomEvent<{\n  /**\n   * The precise point in time at which the tick occurred. Measures as milliseconds since epoch.\n   *\n   * Despite the name, this has nothing to do with event.timeStamp. Do not confuse the two.\n   *\n   * Despite the name, this is not an ISO string. It is a number representing milliseconds since\n   * epoch.\n   */\n  timestamp: number;\n}>;\n\ndeclare global {\n  interface WindowEventMap {\n    'timer-tick': TimerTickEvent;\n  }\n}\n","import type { CustomElement } from '@integrabeauty/custom-elements';\nimport { startTimer, computeTimeLeft } from '../../../lib/countdown-timer.js';\nimport html from './index.html';\nimport styles from './index.scss';\n\n/**\n * Renders product related countdown timer. It is used in a product template context.\n */\nclass ProductDetailCountdownTimer extends HTMLElement implements CustomElement {\n  public static get observedAttributes() {\n    return [\n      'data-heading',\n      'data-end-time',\n      'data-start-time'\n    ];\n  }\n\n  declare readonly dataset: {\n    /**\n     * Stop time as unix epoch time.\n     *\n     * @see https://www.epochconverter.com\n     */\n    endTime: string;\n\n    /**\n     * Heading text shown above the countdown.\n     */\n    heading: string;\n\n    /**\n     * Start time as unix epoch time.\n     *\n     * @see https://www.epochconverter.com\n     */\n    startTime: string;\n  };\n\n  private declare endTime?: number;\n  private declare startTime?: number;\n\n  private onTimerTickBound = this.onTimerTick.bind(this);\n\n  public connectedCallback() {\n    this.initializeShadowRoot();\n\n    addEventListener('timer-tick', this.onTimerTickBound);\n    startTimer();\n    this.render();\n  }\n\n  public disconnectedCallback() {\n    removeEventListener('timer-tick', this.onTimerTickBound);\n  }\n\n  public attributeChangedCallback(name: string, oldValue: string, newValue: string) {\n    this.initializeShadowRoot();\n\n    switch (name) {\n      case 'data-start-time':\n        this.startTime = newValue ? parseInt(newValue, 10) * 1000 : undefined;\n        break;\n      case 'data-end-time':\n        this.endTime = newValue ? parseInt(newValue, 10) * 1000 : undefined;\n        break;\n    }\n\n    if (this.isConnected && oldValue !== newValue) {\n      this.render();\n    }\n  }\n\n  private initializeShadowRoot() {\n    if (!this.shadowRoot) {\n      this.attachShadow({ mode: 'open' });\n      this.shadowRoot.innerHTML = `<style>${styles}</style>${html}`;\n    }\n  }\n\n  private onTimerTick(event: WindowEventMap['timer-tick']) {\n    const wrappingElement = this.shadowRoot.querySelector('p');\n    const daysRemainingElement = this.shadowRoot.querySelector('.days-remaining');\n    const timeRemainingElement = this.shadowRoot.querySelector('.time-remaining');\n\n    // These elements should always exist. However, in the wild we see strange things. For example,\n    // users that use the Safari translate feature. The Safari translate feature does bad things,\n    // like remove empty elements. Like these elements. Rather than continue to crash, we can just\n    // not render correctly in this cases and that is fine.\n\n    if (!wrappingElement) {\n      return;\n    }\n\n    if (!daysRemainingElement) {\n      return;\n    }\n\n    if (!timeRemainingElement) {\n      return;\n    }\n\n    const timeLeft = computeTimeLeft(event.detail.timestamp, this.endTime);\n    if (!timeLeft || this.startTime > event.detail.timestamp) {\n      wrappingElement.classList.remove('active');\n      return;\n    }\n\n    if (!wrappingElement.classList.contains('active')) {\n      wrappingElement.classList.add('active');\n    }\n\n    if (timeLeft.days === 0) {\n      daysRemainingElement.textContent = '';\n    } else if (timeLeft.days === 1) {\n      daysRemainingElement.textContent = '1 day';\n    } else {\n      daysRemainingElement.textContent = [timeLeft.days.toString(), 'days'].join(' ');\n    }\n\n    const hours = timeLeft.hours.toString().padStart(2, '0');\n    const minutes = timeLeft.minutes.toString().padStart(2, '0');\n    const seconds = timeLeft.seconds.toString().padStart(2, '0');\n    const formatted = [hours, ':', minutes, ':', seconds].join('');\n\n    timeRemainingElement.textContent = formatted;\n  }\n\n  private render() {\n    const heading = this.shadowRoot.querySelector('.timer-heading');\n    heading.textContent = this.dataset.heading;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'product-detail-countdown-timer': ProductDetailCountdownTimer;\n  }\n}\n\nif (!customElements.get('product-detail-countdown-timer')) {\n  customElements.define('product-detail-countdown-timer', ProductDetailCountdownTimer);\n}\n"],"names":["tick","event","CustomEvent","detail","timestamp","Date","now","dispatchEvent","ProductDetailCountdownTimer","HTMLElement","observedAttributes","onTimerTickBound","this","onTimerTick","bind","connectedCallback","initializeShadowRoot","addEventListener","setTimeout","window","countdown_timer_interval_started","setInterval","render","disconnectedCallback","removeEventListener","attributeChangedCallback","name","oldValue","newValue","startTime","parseInt","undefined","endTime","isConnected","shadowRoot","attachShadow","mode","innerHTML","wrappingElement","querySelector","daysRemainingElement","timeRemainingElement","timeLeft","currentTimestamp","endTimestamp","secondsLeft","Math","round","seconds","minutesLeft","minutes","hoursLeft","hours","days","text","toString","join","computeTimeLeft","classList","remove","contains","add","textContent","formatted","padStart","dataset","heading","customElements","get","define"],"sourceRoot":""}