Plugin Directory

Changeset 3483265


Ignore:
Timestamp:
03/15/2026 07:32:45 PM (3 weeks ago)
Author:
designplug
Message:

Prepare 2.2.0 release: sync trunk, update readme, and refresh screenshots

Location:
statusdot
Files:
3 added
9 edited

Legend:

Unmodified
Added
Removed
  • statusdot/trunk/assets/css/statusdot-admin.css

    r3476743 r3483265  
    312312.statusdot-live-preview{
    313313  position:sticky;
    314   /* Keep the preview aligned with the top of the main card columns (avoid floating above cards). */
    315   top:96px;
     314  top:118px;
    316315  align-self:flex-start;
    317316  background:#fff;
     
    322321  width:520px;
    323322  max-width:100%;
     323  max-height:calc(100vh - 140px);
     324  overflow:auto;
     325}
     326.statusdot-display-split > .statusdot-live-preview{
     327  top:118px;
     328  margin-top:14px;
     329}
     330.statusdot-profeatures-wrap > .statusdot-live-preview{
     331  top:134px;
    324332}
    325333
     
    334342/* Pro schedule: Status Mode title should match Free styling */
    335343.statusdot-statusmode-title{margin:0 0 8px;font-size:16px;line-height:1.3;}
     344
     345
     346.statusdot-weekly-table input.statusdot-field-error,
     347.statusdot-pro-table input.statusdot-field-error{
     348  border-color:#d63638 !important;
     349  box-shadow:0 0 0 1px #d63638 !important;
     350}
     351
     352.statusdot-break-error-msg{
     353  display:none;
     354  margin-top:6px;
     355  font-size:12px;
     356  line-height:1.4;
     357  color:#d63638;
     358}
     359
     360.statusdot-break-slots{
     361  display:flex;
     362  flex-direction:column;
     363  gap:8px;
     364}
     365.statusdot-break-slot{
     366  display:flex;
     367  align-items:center;
     368  gap:8px;
     369  flex-wrap:wrap;
     370}
     371.statusdot-break-slot__toggle{
     372  min-width:80px;
     373  display:flex;
     374  align-items:center;
     375  gap:6px;
     376  font-size:12px;
     377}
     378.statusdot-break-slot__dash{
     379  color:#646970;
     380}
     381
     382.statusdot-break-slot-actions{
     383  display:flex;
     384  align-items:center;
     385  gap:8px;
     386}
     387.statusdot-break-remove{
     388  margin-left:auto;
     389}
     390.statusdot-break-slot .button-link-delete{
     391  text-decoration:none;
     392}
     393#statusdot-tab-analytics .statusdot-status-icon{
     394  vertical-align:middle;
     395}
     396
     397.statusdot-schedule-tabs{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:14px 0;}
     398.statusdot-schedule-tab.is-active{background:#2271b1;border-color:#2271b1;color:#fff;}
     399.statusdot-schedule-panel{margin-bottom:18px;}
     400.statusdot-schedule-panel[style*="display:none"]{margin-bottom:0;}
     401
     402
     403.statusdot-schedule-tab{display:inline-flex;align-items:center;gap:0;}
     404.statusdot-schedule-tab-label{display:inline-block;}
     405.statusdot-schedule-tab-state{display:inline-block;opacity:.8;}
     406.statusdot-schedule-tab.is-active .statusdot-schedule-tab-state{opacity:.95;}
     407
     408
     409.statusdot-analytics-hours-details{
     410  text-align:left;
     411}
     412.statusdot-analytics-hours-summary{
     413  cursor:pointer;
     414  font-weight:600;
     415  list-style:none;
     416}
     417.statusdot-analytics-hours-summary::-webkit-details-marker{
     418  display:none;
     419}
     420.statusdot-analytics-hours-summary::before{
     421  content:'▸';
     422  display:inline-block;
     423  margin-right:6px;
     424  color:#646970;
     425}
     426.statusdot-analytics-hours-details[open] .statusdot-analytics-hours-summary::before{
     427  content:'▾';
     428}
     429.statusdot-analytics-hours-details-body{
     430  margin-top:8px;
     431}
     432.statusdot-analytics-hours-line + .statusdot-analytics-hours-line{
     433  margin-top:4px;
     434}
     435.statusdot-activity-log-settings-form,
     436.statusdot-activity-log-filter-form{
     437  display:flex;
     438  gap:12px;
     439  align-items:flex-end;
     440  flex-wrap:wrap;
     441  margin-bottom:12px;
     442}
     443.statusdot-field-label{
     444  display:block;
     445  font-weight:600;
     446  margin-bottom:4px;
     447}
     448.statusdot-activity-log-settings-note{
     449  color:#646970;
     450  margin-bottom:4px;
     451}
     452.statusdot-activity-log-settings-actions{
     453  display:flex;
     454  gap:8px;
     455  flex-wrap:wrap;
     456}
     457.statusdot-activity-log-footer{
     458  display:flex;
     459  justify-content:space-between;
     460  align-items:center;
     461  gap:12px;
     462  flex-wrap:wrap;
     463  margin-top:12px;
     464}
     465.statusdot-activity-log-count{
     466  color:#646970;
     467}
     468.statusdot-activity-log-pagination{
     469  display:flex;
     470  gap:6px;
     471  flex-wrap:wrap;
     472}
     473.statusdot-mail-grid{
     474  display:grid;
     475  gap:12px;
     476  max-width:980px;
     477}
     478.statusdot-mail-grid--two{
     479  grid-template-columns:repeat(auto-fit,minmax(240px,1fr));
     480}
     481.statusdot-mail-grid--smtp{
     482  grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
     483}
     484.statusdot-mail-grid label{
     485  display:block;
     486}
     487.statusdot-mail-grid input[type="text"],
     488.statusdot-mail-grid input[type="email"],
     489.statusdot-mail-grid input[type="password"],
     490.statusdot-mail-grid input[type="number"],
     491.statusdot-mail-grid select{
     492  width:100%;
     493  max-width:100%;
     494}
     495.statusdot-mail-smtp-box{
     496  max-width:980px;
     497  padding:14px;
     498  border:1px solid #dcdcde;
     499  border-radius:12px;
     500  background:#fbfbfc;
     501}
     502.statusdot-mail-smtp-toggle{
     503  display:inline-flex;
     504  align-items:center;
     505  gap:8px;
     506  margin-bottom:12px;
     507  font-weight:600;
     508}
     509@media (max-width: 782px){
     510  .statusdot-activity-log-footer{
     511    align-items:flex-start;
     512  }
     513}
  • statusdot/trunk/assets/css/statusdot-status.css

    r3476743 r3483265  
    8686  text-transform: var(--statusdot-text-transform, inherit);
    8787  color: var(--statusdot-text-color, inherit);
     88}
     89.statusdot-wrap.statusdot-wrap--light-text .statusdot-status-text,
     90.statusdot-wrap.statusdot-wrap--light-text .statusdot-status-countdown,
     91.statusdot-wrap.statusdot-wrap--light-text .statusdot-sep{
     92  color:#ffffff !important;
    8893}
    8994.statusdot-wrap .statusdot-label { margin-left: 0; font-size: 13px; line-height: 1; }
  • statusdot/trunk/assets/js/statusdot-admin.js

    r3474528 r3483265  
    1010  }
    1111
     12  function qsa(sel, root) {
     13    return Array.prototype.slice.call((root || document).querySelectorAll(sel));
     14  }
     15
     16  function toMinutes(value) {
     17    if (!value || !/^\d{2}:\d{2}$/.test(value)) return null;
     18    var parts = value.split(':');
     19    return (parseInt(parts[0], 10) * 60) + parseInt(parts[1], 10);
     20  }
     21
     22  function normalizeWindowMinutes(openM, closeM, startM, endM) {
     23    if (openM === null || closeM === null || startM === null || endM === null) return null;
     24    var overnight = closeM <= openM;
     25    if (overnight) {
     26      closeM += 1440;
     27      if (startM < openM) startM += 1440;
     28      if (endM < openM) endM += 1440;
     29    }
     30    return { openM: openM, closeM: closeM, startM: startM, endM: endM };
     31  }
     32
     33  function setRowMessage(row, message) {
     34    var msgEl = row.querySelector('.statusdot-break-error-msg');
     35    if (!msgEl) return;
     36    msgEl.textContent = message || '';
     37    msgEl.style.display = message ? 'block' : 'none';
     38  }
     39
     40  function setSlotError(slot, message) {
     41    qsa('.statusdot-break-time', slot).forEach(function (input) {
     42      input.classList.toggle('statusdot-field-error', !!message);
     43      if (typeof input.setCustomValidity === 'function') {
     44        input.setCustomValidity(message || '');
     45      }
     46    });
     47  }
     48
     49  function getBreakList(row) {
     50    return row.querySelector('.statusdot-break-slot-list') || row.querySelector('.statusdot-break-slots');
     51  }
     52
     53  function getMaxSlots(row) {
     54    var wrap = row.querySelector('.statusdot-break-slots');
     55    var raw = wrap ? parseInt(wrap.getAttribute('data-break-max') || '8', 10) : 8;
     56    return Math.max(1, isNaN(raw) ? 8 : raw);
     57  }
     58
     59  function nextBreakIndex(row) {
     60    var max = -1;
     61    qsa('.statusdot-break-slot', row).forEach(function (slot) {
     62      var idx = parseInt(slot.getAttribute('data-break-slot') || '0', 10);
     63      if (!isNaN(idx) && idx > max) max = idx;
     64    });
     65    return max + 1;
     66  }
     67
     68  function renumberBreakSlots(row) {
     69    qsa('.statusdot-break-slot', row).forEach(function (slot, i) {
     70      var label = slot.querySelector('.statusdot-break-slot-label');
     71      if (label) label.textContent = 'Break ' + (i + 1);
     72    });
     73  }
     74
     75  function addBreakSlot(row) {
     76    var wrap = row.querySelector('.statusdot-break-slots');
     77    var list = getBreakList(row);
     78    var tpl = wrap ? wrap.querySelector('.statusdot-break-slot-template') : null;
     79    if (!wrap || !list || !tpl) return;
     80    if (qsa('.statusdot-break-slot', row).length >= getMaxSlots(row)) return;
     81    var idx = nextBreakIndex(row);
     82    var number = qsa('.statusdot-break-slot', row).length + 1;
     83    var html = (tpl.innerHTML || '')
     84      .replace(/__INDEX__/g, String(idx))
     85      .replace(/__NUMBER__/g, String(number));
     86    var tmp = document.createElement('div');
     87    tmp.innerHTML = html.trim();
     88    if (!tmp.firstElementChild) return;
     89    list.appendChild(tmp.firstElementChild);
     90    renumberBreakSlots(row);
     91    updateBreakFields();
     92  }
     93
     94  function clearBreakSlot(slot) {
     95    var checkbox = slot.querySelector('.statusdot-break-enabled');
     96    if (checkbox) checkbox.checked = false;
     97    qsa('.statusdot-break-time', slot).forEach(function (input) { input.value = ''; });
     98  }
     99
     100  function removeBreakSlot(slot) {
     101    var row = slot.closest('tr');
     102    if (!row) return;
     103    var slots = qsa('.statusdot-break-slot', row);
     104    if (slots.length <= 1) {
     105      clearBreakSlot(slot);
     106    } else {
     107      slot.remove();
     108    }
     109    renumberBreakSlots(row);
     110    updateBreakFields();
     111  }
     112
     113  function validateBreakRows() {
     114    var checkedMode = document.querySelector('input[name="status_mode"]:checked');
     115    var currentMode = checkedMode ? checkedMode.value : 'normal';
     116    var breakModeSupported = currentMode === 'normal' || currentMode === 'open_247';
     117    var breaksToggle = document.getElementById('statusdot-breaks-enabled');
     118    var breaksEnabled = !!(breaksToggle && breaksToggle.checked);
     119
     120    qsa('#statusdot-free-weekly-table tbody tr').forEach(function (row) {
     121      var openInput = row.querySelector('input[name^="open_hour"]');
     122      var closeInput = row.querySelector('input[name^="close_hour"]');
     123      var closedInput = row.querySelector('input[name^="day_closed"]');
     124      var openM = toMinutes(openInput ? openInput.value : '');
     125      var closeM = toMinutes(closeInput ? closeInput.value : '');
     126      var rowMessage = '';
     127      var activeRanges = [];
     128
     129      qsa('.statusdot-break-slot', row).forEach(function (slot) {
     130        var rowToggle = slot.querySelector('.statusdot-break-enabled');
     131        var startInput = slot.querySelector('input[name^="break_start"]');
     132        var endInput = slot.querySelector('input[name^="break_end"]');
     133        var rowEnabled = !!(rowToggle && rowToggle.checked);
     134        var slotMessage = '';
     135        var startM = toMinutes(startInput ? startInput.value : '');
     136        var endM = toMinutes(endInput ? endInput.value : '');
     137
     138        if (breakModeSupported && breaksEnabled && rowEnabled) {
     139          if ((startInput && startInput.value) || (endInput && endInput.value)) {
     140            if (startM === null || endM === null) {
     141              slotMessage = 'Enter both break times in HH:MM.';
     142            } else if (currentMode === 'open_247') {
     143              if (startM >= endM) {
     144                slotMessage = 'Break time must stay within that day and end after it starts.';
     145              } else {
     146                activeRanges.push({ slot: slot, start: startM, end: endM });
     147              }
     148            } else if (closedInput && closedInput.checked) {
     149              slotMessage = 'Breaks are not allowed on a closed day.';
     150            } else {
     151              var normalized = normalizeWindowMinutes(openM, closeM, startM, endM);
     152              if (!normalized) {
     153                slotMessage = 'Break time must stay within the opening hours for this day.';
     154              } else if (normalized.startM >= normalized.endM || normalized.startM < normalized.openM || normalized.endM > normalized.closeM) {
     155                slotMessage = 'Break time must stay within the opening hours for this day.';
     156              } else {
     157                activeRanges.push({ slot: slot, start: normalized.startM, end: normalized.endM });
     158              }
     159            }
     160          }
     161        }
     162
     163        setSlotError(slot, slotMessage);
     164        if (!rowMessage && slotMessage) rowMessage = slotMessage;
     165      });
     166
     167      activeRanges.sort(function (a, b) { return a.start - b.start; });
     168      for (var i = 1; i < activeRanges.length; i++) {
     169        if (activeRanges[i].start < activeRanges[i - 1].end) {
     170          var overlapMessage = 'Break windows cannot overlap.';
     171          setSlotError(activeRanges[i - 1].slot, overlapMessage);
     172          setSlotError(activeRanges[i].slot, overlapMessage);
     173          if (!rowMessage) rowMessage = overlapMessage;
     174        }
     175      }
     176
     177      setRowMessage(row, rowMessage);
     178    });
     179  }
     180
     181  function toggleBreakWarningFields() {
     182    var breakWarningToggle = document.getElementById('statusdot-break-warning-enabled');
     183    var breakWarningMinutes = document.getElementById('statusdot-break-warning-minutes');
     184    var breakWarningContent = document.getElementById('statusdot-break-warning-content');
     185    var breakWarningDisplay = document.getElementById('statusdot-break-warning-display');
     186    var breakWarningRotate = document.getElementById('statusdot-break-warning-rotate');
     187    var checkedMode = document.querySelector('input[name="status_mode"]:checked');
     188    var breakModeSupported = !!(checkedMode && (checkedMode.value === 'normal' || checkedMode.value === 'open_247'));
     189    var breaksToggle = document.getElementById('statusdot-breaks-enabled');
     190    var breaksEnabled = !!(breaksToggle && breaksToggle.checked);
     191    var warningEnabled = !!(breakWarningToggle && breakWarningToggle.checked && breakModeSupported && breaksEnabled);
     192
     193    [breakWarningMinutes, breakWarningContent, breakWarningDisplay].forEach(function (el) {
     194      if (el) el.disabled = !warningEnabled;
     195    });
     196    if (breakWarningRotate) {
     197      breakWarningRotate.disabled = !(warningEnabled && breakWarningDisplay && breakWarningDisplay.value === 'rotate');
     198    }
     199  }
     200
     201  function updateBreakFields() {
     202    var checkedMode = document.querySelector('input[name="status_mode"]:checked');
     203    var breakModeSupported = !!(checkedMode && (checkedMode.value === 'normal' || checkedMode.value === 'open_247'));
     204    var breaksToggle = document.getElementById('statusdot-breaks-enabled');
     205    var breaksEnabled = !!(breaksToggle && breaksToggle.checked);
     206
     207    qsa('#statusdot-free-weekly-table tbody tr').forEach(function (row) {
     208      qsa('.statusdot-break-slot', row).forEach(function (slot) {
     209        var rowToggle = slot.querySelector('.statusdot-break-enabled');
     210        var rowEnabled = !!(rowToggle && rowToggle.checked);
     211        if (rowToggle) rowToggle.disabled = !breakModeSupported || !breaksEnabled;
     212        qsa('.statusdot-break-time', slot).forEach(function (input) {
     213          input.disabled = !(breakModeSupported && breaksEnabled && rowEnabled);
     214        });
     215      });
     216      var addBtn = row.querySelector('.statusdot-break-add');
     217      if (addBtn) {
     218        addBtn.disabled = !breakModeSupported || !breaksEnabled || qsa('.statusdot-break-slot', row).length >= getMaxSlots(row);
     219      }
     220    });
     221
     222    validateBreakRows();
     223    toggleBreakWarningFields();
     224  }
     225
    12226  domReady(function () {
    13     // Free: collapse only the Free weekly rows.
    14     var freeRows = document.querySelectorAll('#statusdot-free-weekly-table tbody tr');
    15     var modeRadios = document.querySelectorAll('input[name="status_mode"]');
     227    var freeRows = qsa('#statusdot-free-weekly-table tbody tr');
     228    var modeRadios = qsa('input[name="status_mode"]');
     229    var form = document.querySelector('form');
    16230
    17231    function toggleFreeRows() {
     
    19233      var checked = document.querySelector('input[name="status_mode"]:checked');
    20234      var val = checked ? checked.value : 'normal';
    21       var show = (val === 'normal');
     235      var show = (val !== 'closed');
    22236      freeRows.forEach(function (row) { row.style.display = show ? '' : 'none'; });
     237      updateBreakFields();
    23238    }
    24239
    25240    modeRadios.forEach(function (r) { r.addEventListener('change', toggleFreeRows); });
     241    document.addEventListener('change', function (e) {
     242      if (e.target.matches('#statusdot-breaks-enabled, #statusdot-break-warning-enabled, #statusdot-break-warning-display, .statusdot-break-enabled, #statusdot-free-weekly-table input[type="time"], #statusdot-free-weekly-table input[type="checkbox"]')) {
     243        updateBreakFields();
     244      }
     245    });
     246    document.addEventListener('input', function (e) {
     247      if (e.target.matches('#statusdot-free-weekly-table input[type="time"]')) {
     248        validateBreakRows();
     249      }
     250    });
     251    document.addEventListener('click', function (e) {
     252      var addBtn = e.target.closest('.statusdot-break-add');
     253      if (addBtn && addBtn.closest('#statusdot-free-weekly-table')) {
     254        e.preventDefault();
     255        var row = addBtn.closest('tr');
     256        if (row) addBreakSlot(row);
     257        return;
     258      }
     259      var removeBtn = e.target.closest('.statusdot-break-remove');
     260      if (removeBtn && removeBtn.closest('#statusdot-free-weekly-table')) {
     261        e.preventDefault();
     262        var slot = removeBtn.closest('.statusdot-break-slot');
     263        if (slot) removeBreakSlot(slot);
     264      }
     265    });
     266
     267    if (form) {
     268      form.addEventListener('submit', function (e) {
     269        validateBreakRows();
     270        var invalid = form.querySelector('.statusdot-field-error');
     271        if (invalid && typeof invalid.reportValidity === 'function') {
     272          e.preventDefault();
     273          invalid.reportValidity();
     274          invalid.focus();
     275          return;
     276        }
     277        qsa('.statusdot-break-enabled, .statusdot-break-time, #statusdot-break-warning-minutes, #statusdot-break-warning-content, #statusdot-break-warning-display, #statusdot-break-warning-rotate', form).forEach(function (field) {
     278          if (field && field.disabled) field.disabled = false;
     279        });
     280      });
     281    }
     282
     283    freeRows.forEach(renumberBreakSlots);
    26284    toggleFreeRows();
    27 
     285    toggleBreakWarningFields();
    28286  });
    29287})();
     
    44302      var url = btn.getAttribute('data-statusdot-dismiss-url');
    45303      if (!url) return;
    46       // Fire-and-forget request; the server sets the dismissed flag and redirects back.
    47304      try {
    48305        fetch(url, { credentials: 'same-origin' }).then(function(){
     
    50307          if (notice) notice.style.display = 'none';
    51308        }).catch(function(){
    52           // Fallback: navigate to URL if fetch fails.
    53309          window.location.href = url;
    54310        });
     
    59315  });
    60316})();
     317
     318
     319// Live status badges on the Free settings page.
     320(function(){
     321  'use strict';
     322  function onReady(fn){
     323    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
     324    else fn();
     325  }
     326  function qs(sel, root){ return (root || document).querySelector(sel); }
     327  function modeStateHtml(payload){
     328    var reason = String((payload && payload.reason) || '').toLowerCase();
     329    if (reason === 'break') return '<span style="color:#dba617;font-weight:600;">(Break)</span>';
     330    if (reason === 'idle' || reason === 'idle_override') return '<span style="color:#dba617;font-weight:600;">(Idle)</span>';
     331    if (reason === 'busy') return '<span style="color:#dba617;font-weight:600;">(Busy)</span>';
     332    if (reason === 'forced_closed' || reason === 'closed') return '<span style="color:#d63638;font-weight:600;">(Closed)</span>';
     333    return '<span style="color:#00a32a;font-weight:600;">(Open)</span>';
     334  }
     335  function weeklyStateHtml(payload){
     336    var reason = String((payload && payload.reason) || '').toLowerCase();
     337    var checked = document.querySelector('input[name="status_mode"]:checked');
     338    var mode = checked ? checked.value : 'normal';
     339    if (reason === 'idle' || reason === 'idle_override') return '<span style="color:#dba617;font-weight:600;">(Idle override is active)</span>';
     340    if (reason === 'break') return '<span style="color:#dba617;font-weight:600;">(Break time is active)</span>';
     341    if (reason === 'busy') return '<span style="color:#dba617;font-weight:600;">(Active - Busy Mode Enabled)</span>';
     342    if (mode === 'closed' || reason === 'forced_closed' || reason === 'closed') return '<span style="color:#d63638;font-weight:600;">(Inactive)</span>';
     343    return '<span style="color:#00a32a;font-weight:600;">(Active)</span>';
     344  }
     345  function idleHeadingHtml(active){
     346    return active ? '<span style="color:#00a32a;font-weight:600;">(Active)</span>' : '<span style="color:#d63638;font-weight:600;">(Inactive)</span>';
     347  }
     348  function idleCanStart(payload){
     349    var reason = String((payload && payload.reason) || '').toLowerCase();
     350    return !(reason === 'forced_closed' || reason === 'closed');
     351  }
     352  function updateIdleStartAvailability(payload){
     353    var startButton = qs('.statusdot-idle-start-button');
     354    var help = qs('.statusdot-idle-start-help');
     355    var allowed = idleCanStart(payload);
     356    if (startButton) startButton.disabled = !allowed;
     357    if (help) help.style.display = allowed ? 'none' : '';
     358  }
     359  function refresh(){
     360    if (window.StatusDotAdminPro) return;
     361    if (!window.StatusDotAdmin || !window.StatusDotAdmin.ajax_url) return;
     362    var fd = new window.FormData();
     363    fd.append('action', 'statusdot_get_status');
     364    fd.append('nonce', window.StatusDotAdmin.nonce || '');
     365    window.fetch(window.StatusDotAdmin.ajax_url, { method:'POST', credentials:'same-origin', body: fd })
     366      .then(function(r){ return r.json(); })
     367      .then(function(res){
     368        if (!res || !res.success || !res.data) return;
     369        var payload = res.data;
     370        var modeState = qs('.statusdot-live-statusmode-state');
     371        var weeklyState = qs('.statusdot-live-weekly-state');
     372        var idleHeading = qs('.statusdot-idle-live-heading-state');
     373        var idlePill = qs('.statusdot-idle-live-pill');
     374        var reason = String((payload && payload.reason) || '').toLowerCase();
     375        var idleActive = !!(payload && !payload.is_break && (reason === 'idle' || reason === 'idle_override' || payload.is_idle));
     376        if (modeState) modeState.innerHTML = modeStateHtml(payload);
     377        if (weeklyState) weeklyState.innerHTML = weeklyStateHtml(payload);
     378        if (idleHeading) idleHeading.innerHTML = idleHeadingHtml(idleActive);
     379        if (idlePill) {
     380          idlePill.textContent = idleActive ? 'Idle is active' : 'Idle is not active';
     381          idlePill.classList.toggle('statusdot-pill--active', idleActive);
     382        }
     383        updateIdleStartAvailability(payload);
     384      }).catch(function(){});
     385  }
     386  onReady(function(){
     387    if (window.StatusDotAdminPro) return;
     388    refresh();
     389    setInterval(refresh, 5000);
     390  });
     391})();
  • statusdot/trunk/assets/js/statusdot-frontend.js

    r3476743 r3483265  
    1212  function qsa(sel) {
    1313    return Array.prototype.slice.call(document.querySelectorAll(sel));
     14  }
     15
     16  function normalizeVariantList(rawVariants) {
     17    if (!Array.isArray(rawVariants)) return [];
     18    return rawVariants.map(function (variant) {
     19      var next = variant || {};
     20      var endMs = parseInt(next.countdown_end_ms || '0', 10);
     21      if (!endMs && typeof next.countdown_seconds !== 'undefined' && next.countdown_seconds !== null) {
     22        var secs = parseInt(next.countdown_seconds, 10);
     23        if (!isNaN(secs) && secs > 0) {
     24          endMs = Date.now() + (secs * 1000);
     25        }
     26      }
     27      return {
     28        text: next.text || '',
     29        countdown: next.countdown || '',
     30        countdown_end_ms: (!isNaN(endMs) && endMs > 0) ? endMs : 0
     31      };
     32    });
     33  }
     34
     35  function getActiveWrapVariant(wrap) {
     36    var variants = wrap && wrap._statusdotDisplayVariants;
     37    if (!variants || !variants.length) return null;
     38    if (variants.length === 1) return variants[0];
     39    var rotateSeconds = parseInt(wrap._statusdotRotateSeconds || '0', 10);
     40    if (isNaN(rotateSeconds) || rotateSeconds <= 0) rotateSeconds = 4;
     41    var idx = Math.floor(Date.now() / 1000 / rotateSeconds) % variants.length;
     42    return variants[idx] || variants[0];
     43  }
     44
     45  function renderWrapVariant(wrap) {
     46    if (!wrap) return;
     47    var variant = getActiveWrapVariant(wrap);
     48    if (!variant) return;
     49    var textNode = wrap.querySelector('.statusdot-status-text');
     50    var cdNode = wrap.querySelector('.statusdot-status-countdown');
     51    var sepNode = wrap.querySelector('.statusdot-sep');
     52    if (textNode) {
     53      textNode.textContent = variant.text || '';
     54      textNode.style.display = (variant.text && variant.text.trim()) ? '' : 'none';
     55    }
     56    if (cdNode) {
     57      var diff = 0;
     58      if (variant.countdown_end_ms) {
     59        diff = Math.max(0, Math.round((variant.countdown_end_ms - Date.now()) / 1000));
     60      }
     61      cdNode.textContent = diff > 0 ? fmtHMS(diff) : (variant.countdown || '');
     62      cdNode.style.display = (cdNode.textContent && cdNode.textContent.trim()) ? '' : 'none';
     63      if (variant.countdown_end_ms && diff === 0 && !wrap.getAttribute('data-statusdot-boundary-refresh')) {
     64        wrap.setAttribute('data-statusdot-boundary-refresh', '1');
     65        setTimeout(function(){
     66          if (window.StatusDotForceRefresh) {
     67            try { window.StatusDotForceRefresh(); } catch (e) {}
     68          }
     69        }, 150);
     70      }
     71      if (variant.countdown_end_ms && diff > 0) {
     72        wrap.removeAttribute('data-statusdot-boundary-refresh');
     73      }
     74    }
     75    if (sepNode) {
     76      var hasText = textNode && textNode.textContent && textNode.textContent.trim();
     77      var hasCd = cdNode && cdNode.textContent && cdNode.textContent.trim();
     78      sepNode.style.display = (hasText && hasCd) ? '' : 'none';
     79    }
    1480  }
    1581
     
    38104  var labelEls = qsa('[data-statusdot-label-for="' + schedule + '"]');
    39105  labelEls.forEach(function (le) { le.textContent = data.label || ''; });
    40   // inline text + countdown (Pro output)
     106  // inline text + countdown (Free/Pro output)
    41107  if (wrap) {
    42     var textNode = wrap.querySelector('.statusdot-status-text');
    43     if (textNode && typeof data.text !== 'undefined') {
    44       textNode.textContent = data.text || '';
    45       if (data.text) { textNode.style.display = ''; } else { textNode.style.display = 'none'; }
    46     }
    47     var cdNode = wrap.querySelector('.statusdot-status-countdown');
    48     if (cdNode && (typeof data.countdown !== 'undefined' || typeof data.countdown_seconds !== 'undefined')) {
    49       // prefer seconds for live ticking
    50       if (typeof data.countdown_seconds !== 'undefined' && data.countdown_seconds !== null) {
    51         var secs = parseInt(data.countdown_seconds, 10);
    52         if (!isNaN(secs)) {
    53           wrap.setAttribute('data-statusdot-countdown-end', String(Date.now() + (secs * 1000)));
    54         }
    55       }
    56       if (typeof data.countdown !== 'undefined') {
    57         cdNode.textContent = data.countdown || '';
    58       } else {
    59         cdNode.textContent = cdNode.textContent || '';
    60       }
    61 
    62       // Hide countdown when empty
    63       if (!cdNode.textContent || !cdNode.textContent.trim()) {
    64         cdNode.style.display = 'none';
    65         wrap.removeAttribute('data-statusdot-countdown-end');
    66       } else {
    67         cdNode.style.display = '';
    68       }
    69 
    70       // Separator visibility (Free/Pro)
    71       // NOTE:
    72       // The separator BETWEEN status text and the countdown time is always a plain space
    73       // rendered by the server as "&nbsp;" inside .statusdot-sep.
    74       // The configurable separator ( - / — / | / • ) is part of the status text itself.
    75       var sepNode = wrap.querySelector('.statusdot-sep');
    76       if (sepNode) {
    77         var tnode = wrap.querySelector('.statusdot-status-text');
    78         var hasText = tnode && tnode.textContent && tnode.textContent.trim();
    79         var hasCd = cdNode && cdNode.textContent && cdNode.textContent.trim();
    80         sepNode.style.display = (hasText && hasCd) ? '' : 'none';
    81       }
    82 
    83       // Fallback: if server only returned a formatted countdown (HH:MM:SS / MM:SS),
    84       // parse it and enable local 1s ticking.
    85       if (!wrap.getAttribute('data-statusdot-countdown-end')) {
    86         var t = (cdNode.textContent || '').trim();
    87         var m = t.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/);
    88         if (m) {
    89           var hh = parseInt(m[1] || '0', 10);
    90           var mm = parseInt(m[2] || '0', 10);
    91           var ss = parseInt(m[3] || '0', 10);
    92           var total = (hh * 3600) + (mm * 60) + ss;
    93           if (!isNaN(total) && total >= 0) {
    94             wrap.setAttribute('data-statusdot-countdown-end', String(Date.now() + (total * 1000)));
     108    if (Array.isArray(data.display_variants) && data.display_variants.length) {
     109      wrap._statusdotDisplayVariants = normalizeVariantList(data.display_variants);
     110      wrap._statusdotRotateSeconds = parseInt(data.display_rotation_seconds || '0', 10) || 0;
     111      wrap.removeAttribute('data-statusdot-countdown-end');
     112      renderWrapVariant(wrap);
     113    } else {
     114      wrap._statusdotDisplayVariants = null;
     115      wrap._statusdotRotateSeconds = 0;
     116
     117      var textNode = wrap.querySelector('.statusdot-status-text');
     118      if (textNode && typeof data.text !== 'undefined') {
     119        textNode.textContent = data.text || '';
     120        if (data.text) { textNode.style.display = ''; } else { textNode.style.display = 'none'; }
     121      }
     122      var cdNode = wrap.querySelector('.statusdot-status-countdown');
     123      if (cdNode && (typeof data.countdown !== 'undefined' || typeof data.countdown_seconds !== 'undefined')) {
     124        var endMs = parseInt(data.countdown_end_ms || '0', 10);
     125        if (!endMs && typeof data.countdown_seconds !== 'undefined' && data.countdown_seconds !== null) {
     126          var secs = parseInt(data.countdown_seconds, 10);
     127          if (!isNaN(secs) && secs > 0) {
     128            endMs = Date.now() + (secs * 1000);
    95129          }
    96130        }
    97       }
    98 
    99       if (data.countdown || cdNode.textContent) { cdNode.style.display = ''; } else { cdNode.style.display = 'none'; }
    100     }
    101 
    102     // separator visibility handled above
     131        if (!isNaN(endMs) && endMs > 0) {
     132          wrap.setAttribute('data-statusdot-countdown-end', String(endMs));
     133          var diff = Math.max(0, Math.round((endMs - Date.now()) / 1000));
     134          cdNode.textContent = diff > 0 ? fmtHMS(diff) : '';
     135        } else if (typeof data.countdown !== 'undefined') {
     136          cdNode.textContent = data.countdown || '';
     137        } else {
     138          cdNode.textContent = cdNode.textContent || '';
     139        }
     140
     141        // Hide countdown when empty
     142        if (!cdNode.textContent || !cdNode.textContent.trim()) {
     143          cdNode.style.display = 'none';
     144          wrap.removeAttribute('data-statusdot-countdown-end');
     145          wrap.removeAttribute('data-statusdot-boundary-refresh');
     146        } else {
     147          cdNode.style.display = '';
     148          wrap.removeAttribute('data-statusdot-boundary-refresh');
     149        }
     150
     151        var sepNode = wrap.querySelector('.statusdot-sep');
     152        if (sepNode) {
     153          var tnode = wrap.querySelector('.statusdot-status-text');
     154          var hasText = tnode && tnode.textContent && tnode.textContent.trim();
     155          var hasCd = cdNode && cdNode.textContent && cdNode.textContent.trim();
     156          sepNode.style.display = (hasText && hasCd) ? '' : 'none';
     157        }
     158
     159        if (!wrap.getAttribute('data-statusdot-countdown-end')) {
     160          var t = (cdNode.textContent || '').trim();
     161          var m = t.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/);
     162          if (m) {
     163            var hh = parseInt(m[1] || '0', 10);
     164            var mm = parseInt(m[2] || '0', 10);
     165            var ss = parseInt(m[3] || '0', 10);
     166            var total = (hh * 3600) + (mm * 60) + ss;
     167            if (!isNaN(total) && total >= 0) {
     168              wrap.setAttribute('data-statusdot-countdown-end', String(Date.now() + (total * 1000)));
     169            }
     170          }
     171        }
     172
     173        if (data.countdown || cdNode.textContent) { cdNode.style.display = ''; } else { cdNode.style.display = 'none'; }
     174      }
     175    }
    103176  }
    104177
     
    124197
    125198  function getMinRefreshSeconds(els) {
    126     var min = 30;
     199    var min = 20;
    127200    els.forEach(function (el) {
    128       var v = parseInt(el.getAttribute('data-statusdot-refresh') || '30', 10);
     201      var v = parseInt(el.getAttribute('data-statusdot-refresh') || '20', 10);
    129202      if (!isNaN(v) && v > 0) min = Math.min(min, v);
    130203    });
    131204      // We tick countdown locally in Pro; keep network polling at a sane interval.
    132205    if (window.StatusDotData && StatusDotData.pro_nonce) {
    133       return Math.max(30, min);
    134     }
    135     return Math.max(5, min);
     206      return Math.max(15, min);
     207    }
     208    return Math.max(15, min);
    136209
    137210  }
     
    194267    };
    195268
     269    window.StatusDotForceRefresh = function(){ return tick(); };
     270    window.StatusDotRenderWrapVariant = renderWrapVariant;
    196271    tick();
    197272    window.setInterval(tick, getMinRefreshSeconds(icons) * 1000);
     
    223298
    224299  function tickCountdowns() {
     300    qsa('.statusdot-wrap').forEach(function(w){
     301      if (w && w._statusdotDisplayVariants && w._statusdotDisplayVariants.length) {
     302        if (window.StatusDotRenderWrapVariant) {
     303          window.StatusDotRenderWrapVariant(w);
     304        }
     305      }
     306    });
     307
    225308    var wraps = qsa('.statusdot-wrap[data-statusdot-countdown-end]');
    226309    wraps.forEach(function(w){
     310      if (w && w._statusdotDisplayVariants && w._statusdotDisplayVariants.length) return;
    227311      var endMs = parseInt(w.getAttribute('data-statusdot-countdown-end') || '0', 10);
    228312      if (!endMs) return;
     
    232316      if (diff < 0) diff = 0;
    233317      cdNode.textContent = diff > 0 ? fmtHMS(diff) : '';
    234       // Show/hide the spacer between text and countdown
     318      if (diff === 0 && !w.getAttribute('data-statusdot-boundary-refresh')) {
     319        w.setAttribute('data-statusdot-boundary-refresh', '1');
     320        setTimeout(function(){
     321          if (window.StatusDotForceRefresh) {
     322            try { window.StatusDotForceRefresh(); } catch (e) {}
     323          }
     324        }, 150);
     325      }
    235326      var sep = w.querySelector('.statusdot-sep');
    236327      if (sep) {
  • statusdot/trunk/includes/class-statusdot-opening-hours.php

    r3476743 r3483265  
    2020    const OPTION_SHOW_TIME_CLOSED = 'statusdot_show_time_closed';
    2121    const OPTION_SEPARATOR_MODE   = 'statusdot_separator_mode';
     22    const OPTION_LIGHT_TEXT      = 'statusdot_use_light_text';
    2223
    2324    // Idle override (Back in...) (Free/Pro)
     
    2627    const OPTION_IDLE_AFTER   = 'statusdot_idle_after'; // schedule|open_247|closed|busy
    2728
     29    // Break times (Free/Pro master; Pro has customizable text)
     30    const OPTION_BREAKS_ENABLED       = 'statusdot_breaks_enabled';
     31    const OPTION_BREAK_ENABLED_PREFIX = 'statusdot_break_enabled_';
     32    const OPTION_BREAK_START_PREFIX   = 'statusdot_break_start_';
     33    const OPTION_BREAK_END_PREFIX     = 'statusdot_break_end_';
     34    const OPTION_BREAKS_PREFIX        = 'statusdot_breaks_';
     35    const BREAK_SLOTS_PER_DAY         = 8;
     36
     37    // Pre-break warning (Free/Pro shared behavior; Pro adds customizable text only)
     38    const OPTION_BREAK_WARNING_ENABLED = 'statusdot_break_warning_enabled';
     39    const OPTION_BREAK_WARNING_MINUTES = 'statusdot_break_warning_minutes';
     40    const OPTION_BREAK_WARNING_CONTENT = 'statusdot_break_warning_content'; // countdown|time|both
     41    const OPTION_BREAK_WARNING_DISPLAY = 'statusdot_break_warning_display'; // warning_only|rotate|normal_only
     42    const OPTION_BREAK_WARNING_ROTATE  = 'statusdot_break_warning_rotate'; // seconds
    2843
    2944    public static function init() {
     
    3954        // Migration helper (reads old MU keys if present)
    4055        add_action('admin_init', [__CLASS__, 'maybe_migrate_old_options']);
     56        add_action('admin_init', [__CLASS__, 'maybe_sync_from_saved_pro_default']);
    4157    }
    4258
     
    5369    public static function days() {
    5470        return ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
     71    }
     72
     73
     74    public static function break_slot_count() : int {
     75        return self::BREAK_SLOTS_PER_DAY;
     76    }
     77
     78    public static function normalize_break_items( $raw_day ) : array {
     79        $items = [];
     80
     81        if ( is_array( $raw_day ) && isset( $raw_day['items'] ) && is_array( $raw_day['items'] ) ) {
     82            $items = $raw_day['items'];
     83        } elseif ( is_array( $raw_day ) && ( isset( $raw_day['enabled'] ) || isset( $raw_day['start'] ) || isset( $raw_day['end'] ) ) ) {
     84            $items = [ $raw_day ];
     85        } elseif ( is_array( $raw_day ) ) {
     86            $items = $raw_day;
     87        }
     88
     89        $normalized = [];
     90        foreach ( array_values( $items ) as $item ) {
     91            if ( ! is_array( $item ) ) {
     92                continue;
     93            }
     94            $normalized[] = [
     95                'enabled' => ! empty( $item['enabled'] ) ? 1 : 0,
     96                'start'   => self::sanitize_optional_clock_time( $item['start'] ?? '' ),
     97                'end'     => self::sanitize_optional_clock_time( $item['end'] ?? '' ),
     98            ];
     99        }
     100
     101        $max = max( 1, self::break_slot_count() );
     102        $normalized = array_slice( $normalized, 0, $max );
     103
     104        while ( count( $normalized ) > 1 ) {
     105            $last = end( $normalized );
     106            if ( ! is_array( $last ) || ! empty( $last['enabled'] ) || ! empty( $last['start'] ) || ! empty( $last['end'] ) ) {
     107                break;
     108            }
     109            array_pop( $normalized );
     110        }
     111
     112        if ( empty( $normalized ) ) {
     113            $normalized[] = [ 'enabled' => 0, 'start' => '', 'end' => '' ];
     114        }
     115
     116        return [ 'items' => array_values( $normalized ) ];
     117    }
     118
     119    private static function build_break_validation_range( array $item, bool $is_closed_today, string $open_time, string $close_time, string $mode ) : ?array {
     120        $start = self::sanitize_optional_clock_time( $item['start'] ?? '' );
     121        $end   = self::sanitize_optional_clock_time( $item['end'] ?? '' );
     122        if ( $start === '' || $end === '' ) {
     123            return null;
     124        }
     125
     126        if ( $mode === 'open_247' ) {
     127            if ( ! self::full_day_break_window_is_valid( $start, $end ) ) {
     128                return null;
     129            }
     130            return [
     131                'start' => self::time_to_minutes( $start ),
     132                'end'   => self::time_to_minutes( $end ),
     133            ];
     134        }
     135
     136        if ( ! self::break_window_is_valid( $is_closed_today, $open_time, $close_time, $start, $end ) ) {
     137            return null;
     138        }
     139
     140        $open_m  = self::time_to_minutes( $open_time );
     141        $close_m = self::time_to_minutes( $close_time );
     142        $start_m = self::time_to_minutes( $start );
     143        $end_m   = self::time_to_minutes( $end );
     144        if ( $open_m === null || $close_m === null || $start_m === null || $end_m === null ) {
     145            return null;
     146        }
     147        list( $open_m, $close_m ) = self::normalize_window_clock_minutes( $open_m, $close_m );
     148        $start_m = self::map_time_minutes_into_window( $start_m, $open_m, $close_m );
     149        $end_m   = self::map_time_minutes_into_window( $end_m, $open_m, $close_m );
     150        if ( $start_m === null || $end_m === null || $start_m >= $end_m ) {
     151            return null;
     152        }
     153        return [ 'start' => $start_m, 'end' => $end_m ];
     154    }
     155
     156    public static function validate_break_items_config( array $items, bool $is_closed_today, string $open_time, string $close_time, string $mode = 'normal' ) : array {
     157        $errors = [];
     158        $ranges = [];
     159
     160        foreach ( array_values( $items ) as $slot => $item ) {
     161            if ( ! is_array( $item ) || empty( $item['enabled'] ) ) {
     162                continue;
     163            }
     164
     165            $start = self::sanitize_optional_clock_time( $item['start'] ?? '' );
     166            $end   = self::sanitize_optional_clock_time( $item['end'] ?? '' );
     167            if ( $start === '' || $end === '' ) {
     168                $errors[ $slot ] = __( 'Enter both break times in HH:MM.', 'statusdot' );
     169                continue;
     170            }
     171
     172            $range = self::build_break_validation_range( [ 'start' => $start, 'end' => $end ], $is_closed_today, $open_time, $close_time, $mode );
     173            if ( ! is_array( $range ) ) {
     174                $errors[ $slot ] = ( $mode === 'open_247' )
     175                    ? __( 'Break time must stay within that day and end after it starts.', 'statusdot' )
     176                    : __( 'Break time must stay within the opening hours for this day.', 'statusdot' );
     177                continue;
     178            }
     179
     180            $ranges[] = [
     181                'slot'  => $slot,
     182                'start' => (int) $range['start'],
     183                'end'   => (int) $range['end'],
     184            ];
     185        }
     186
     187        usort( $ranges, static function( $a, $b ) {
     188            return (int) $a['start'] <=> (int) $b['start'];
     189        } );
     190
     191        for ( $i = 1; $i < count( $ranges ); $i++ ) {
     192            $prev = $ranges[ $i - 1 ];
     193            $curr = $ranges[ $i ];
     194            if ( (int) $curr['start'] < (int) $prev['end'] ) {
     195                $message = __( 'Break windows cannot overlap.', 'statusdot' );
     196                $errors[ (int) $prev['slot'] ] = $message;
     197                $errors[ (int) $curr['slot'] ] = $message;
     198            }
     199        }
     200
     201        ksort( $errors );
     202        return $errors;
     203    }
     204
     205    public static function get_break_items_for_day( string $day ) : array {
     206        $stored = get_option( self::OPTION_BREAKS_PREFIX . $day, null );
     207        if ( is_array( $stored ) ) {
     208            return self::normalize_break_items( $stored )['items'];
     209        }
     210
     211        return self::normalize_break_items( [
     212            'enabled' => (int) get_option( self::OPTION_BREAK_ENABLED_PREFIX . $day, 0 ),
     213            'start'   => get_option( self::OPTION_BREAK_START_PREFIX . $day, '' ),
     214            'end'     => get_option( self::OPTION_BREAK_END_PREFIX . $day, '' ),
     215        ] )['items'];
     216    }
     217
     218    private static function first_enabled_break_item( array $items ) : array {
     219        foreach ( $items as $item ) {
     220            if ( ! empty( $item['enabled'] ) ) {
     221                return [
     222                    'enabled' => 1,
     223                    'start'   => self::sanitize_optional_clock_time( $item['start'] ?? '' ),
     224                    'end'     => self::sanitize_optional_clock_time( $item['end'] ?? '' ),
     225                ];
     226            }
     227        }
     228
     229        return [ 'enabled' => 0, 'start' => '', 'end' => '' ];
     230    }
     231
     232    private static function find_active_break_from_items( array $items, int $current_m, int $open_m, int $close_m, DateTimeImmutable $base_dt ) : ?array {
     233        foreach ( $items as $item ) {
     234            if ( empty( $item['enabled'] ) ) {
     235                continue;
     236            }
     237            $start_m = self::map_time_minutes_into_window( self::time_to_minutes( $item['start'] ?? '' ), $open_m, $close_m );
     238            $end_m   = self::map_time_minutes_into_window( self::time_to_minutes( $item['end'] ?? '' ), $open_m, $close_m );
     239            if ( $start_m === null || $end_m === null || $start_m >= $end_m ) {
     240                continue;
     241            }
     242            if ( $current_m >= $start_m && $current_m < $end_m ) {
     243                $end_dt = $base_dt->modify( '+' . $end_m . ' minutes' );
     244                return [
     245                    'start'       => self::sanitize_optional_clock_time( $item['start'] ?? '' ),
     246                    'end'         => self::sanitize_optional_clock_time( $item['end'] ?? '' ),
     247                    'end_ts'      => $end_dt->getTimestamp(),
     248                    'end_display' => self::format_display_time( $end_dt ),
     249                ];
     250            }
     251        }
     252
     253        return null;
     254    }
     255
     256    private static function find_upcoming_break_warning_from_items( array $items, int $warning_minutes, DateTimeImmutable $now, int $open_m, int $close_m, DateTimeImmutable $base_dt ) : ?array {
     257        $best = null;
     258        foreach ( $items as $item ) {
     259            if ( empty( $item['enabled'] ) ) {
     260                continue;
     261            }
     262            $start_m = self::map_time_minutes_into_window( self::time_to_minutes( $item['start'] ?? '' ), $open_m, $close_m );
     263            $end_m   = self::map_time_minutes_into_window( self::time_to_minutes( $item['end'] ?? '' ), $open_m, $close_m );
     264            if ( $start_m === null || $end_m === null || $start_m >= $end_m ) {
     265                continue;
     266            }
     267            $start_dt = $base_dt->modify( '+' . $start_m . ' minutes' );
     268            $in_seconds = (int) max( 0, $start_dt->getTimestamp() - $now->getTimestamp() );
     269            if ( $in_seconds <= 0 || $in_seconds > ( $warning_minutes * 60 ) ) {
     270                continue;
     271            }
     272            if ( $best === null || $in_seconds < $best['in_seconds'] ) {
     273                $best = [
     274                    'start'          => self::sanitize_optional_clock_time( $item['start'] ?? '' ),
     275                    'start_ts'       => $start_dt->getTimestamp(),
     276                    'start_display'  => self::format_display_time( $start_dt ),
     277                    'in_seconds'     => $in_seconds,
     278                ];
     279            }
     280        }
     281
     282        return $best;
     283    }
     284
     285
     286    public static function get_free_schedule_payload() : array {
     287        $weekly = [];
     288        $breaks = [];
     289        foreach ( self::days() as $day ) {
     290            $weekly[ $day ] = [
     291                'open'   => self::sanitize_clock_time( get_option( self::OPTION_OPEN_PREFIX . $day, '09:00' ), '09:00' ),
     292                'close'  => self::sanitize_clock_time( get_option( self::OPTION_CLOSE_PREFIX . $day, '17:00' ), '17:00' ),
     293                'closed' => (int) get_option( self::OPTION_DAY_CLOSED_PREFIX . $day, 0 ),
     294            ];
     295            $breaks[ $day ] = [
     296                'items' => self::get_break_items_for_day( $day ),
     297            ];
     298        }
     299
     300        $mode = get_option( self::OPTION_MODE, 'normal' );
     301        if ( ! in_array( $mode, [ 'normal', 'closed', 'open_247' ], true ) ) {
     302            $mode = 'normal';
     303        }
     304
     305        return [
     306            'mode'           => $mode,
     307            'busy_manual'    => (int) get_option( self::OPTION_BUSY, 0 ),
     308            'breaks_enabled' => (int) get_option( self::OPTION_BREAKS_ENABLED, 0 ),
     309            'weekly'         => $weekly,
     310            'breaks'         => $breaks,
     311            'break_warning'  => self::get_break_warning_settings(),
     312        ];
     313    }
     314
     315    public static function save_free_schedule_payload( array $payload, bool $bump_rev = true ) : void {
     316        $normalized = self::normalize_free_schedule_payload( $payload );
     317
     318        update_option( self::OPTION_MODE, $normalized['mode'] );
     319        update_option( self::OPTION_BUSY, $normalized['busy_manual'] );
     320        update_option( self::OPTION_BREAKS_ENABLED, $normalized['breaks_enabled'] );
     321        update_option( self::OPTION_BREAK_WARNING_ENABLED, $normalized['break_warning']['enabled'] );
     322        update_option( self::OPTION_BREAK_WARNING_MINUTES, $normalized['break_warning']['minutes'] );
     323        update_option( self::OPTION_BREAK_WARNING_CONTENT, $normalized['break_warning']['content'] );
     324        update_option( self::OPTION_BREAK_WARNING_DISPLAY, $normalized['break_warning']['display'] );
     325        update_option( self::OPTION_BREAK_WARNING_ROTATE, $normalized['break_warning']['rotate_seconds'] );
     326
     327        foreach ( self::days() as $day ) {
     328            $weekly = $normalized['weekly'][ $day ];
     329            $break  = $normalized['breaks'][ $day ];
     330            $break_items = self::normalize_break_items( $break )['items'];
     331            $first_break = self::first_enabled_break_item( $break_items );
     332
     333            update_option( self::OPTION_DAY_CLOSED_PREFIX . $day, $weekly['closed'] );
     334            update_option( self::OPTION_OPEN_PREFIX . $day, $weekly['open'] );
     335            update_option( self::OPTION_CLOSE_PREFIX . $day, $weekly['close'] );
     336            update_option( self::OPTION_BREAKS_PREFIX . $day, [ 'items' => $break_items ] );
     337            update_option( self::OPTION_BREAK_ENABLED_PREFIX . $day, $first_break['enabled'] );
     338            update_option( self::OPTION_BREAK_START_PREFIX . $day, $first_break['start'] );
     339            update_option( self::OPTION_BREAK_END_PREFIX . $day, $first_break['end'] );
     340        }
     341
     342        self::maybe_sync_saved_pro_default_from_free_payload( $normalized );
     343        update_option( 'statusdot_free_pro_mirror_hash', self::free_schedule_payload_hash( $normalized ) );
     344
     345        if ( $bump_rev ) {
     346            update_option( 'statusdot_settings_rev', time() );
     347        }
     348    }
     349
     350    public static function free_schedule_payload_hash( ?array $payload = null ) : string {
     351        if ( $payload === null ) {
     352            $payload = self::get_free_schedule_payload();
     353        }
     354        $normalized = self::normalize_free_schedule_payload( $payload );
     355        return md5( wp_json_encode( $normalized ) );
     356    }
     357
     358    private static function maybe_sync_saved_pro_default_from_free_payload( array $payload ) : void {
     359        $normalized = self::normalize_free_schedule_payload( $payload );
     360        $raw = get_option( 'statusdot_pro_schedules', null );
     361        if ( ! is_array( $raw ) ) {
     362            return;
     363        }
     364
     365        $default = [];
     366        if ( isset( $raw['default'] ) && is_array( $raw['default'] ) ) {
     367            $default = $raw['default'];
     368        }
     369
     370        $default['mode']           = $normalized['mode'];
     371        $default['busy_manual']    = $normalized['busy_manual'];
     372        $default['breaks_enabled'] = $normalized['breaks_enabled'];
     373        $default['weekly']         = $normalized['weekly'];
     374        $default['breaks']         = $normalized['breaks'];
     375        $default['break_warning']  = $normalized['break_warning'];
     376
     377        $raw['default'] = $default;
     378        update_option( 'statusdot_pro_schedules', $raw );
     379    }
     380
     381    private static function normalize_free_schedule_payload( array $payload ) : array {
     382        $weekly = [];
     383        $breaks = [];
     384        foreach ( self::days() as $day ) {
     385            $weekly_raw = isset( $payload['weekly'][ $day ] ) && is_array( $payload['weekly'][ $day ] ) ? $payload['weekly'][ $day ] : [];
     386            $break_raw  = isset( $payload['breaks'][ $day ] ) && is_array( $payload['breaks'][ $day ] ) ? $payload['breaks'][ $day ] : [];
     387
     388            $weekly[ $day ] = [
     389                'open'   => self::sanitize_clock_time( $weekly_raw['open'] ?? '09:00', '09:00' ),
     390                'close'  => self::sanitize_clock_time( $weekly_raw['close'] ?? '17:00', '17:00' ),
     391                'closed' => ! empty( $weekly_raw['closed'] ) ? 1 : 0,
     392            ];
     393            $breaks[ $day ] = self::normalize_break_items( $break_raw );
     394        }
     395
     396        $mode = isset( $payload['mode'] ) ? (string) $payload['mode'] : 'normal';
     397        if ( ! in_array( $mode, [ 'normal', 'closed', 'open_247' ], true ) ) {
     398            $mode = 'normal';
     399        }
     400
     401        return [
     402            'mode'           => $mode,
     403            'busy_manual'    => ! empty( $payload['busy_manual'] ) ? 1 : 0,
     404            'breaks_enabled' => ! empty( $payload['breaks_enabled'] ) ? 1 : 0,
     405            'weekly'         => $weekly,
     406            'breaks'         => $breaks,
     407            'break_warning'  => self::normalize_break_warning_config( isset( $payload['break_warning'] ) && is_array( $payload['break_warning'] ) ? $payload['break_warning'] : [] ),
     408        ];
    55409    }
    56410
     
    129483    }
    130484
     485    /**
     486     * Sanitize an optional clock time. Returns an empty string when blank.
     487     */
     488    private static function sanitize_optional_clock_time( $value ) : string {
     489        if ( $value === null ) {
     490            return '';
     491        }
     492        $raw = trim( (string) $value );
     493        if ( $raw === '' ) {
     494            return '';
     495        }
     496        if ( preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)$/' , $raw, $m ) ) {
     497            return sprintf( '%02d:%02d', (int) $m[1], (int) $m[2] );
     498        }
     499        return '';
     500    }
     501
     502    private static function time_to_minutes( string $time ) : ?int {
     503        if ( ! preg_match( '/^(\d{2}):(\d{2})$/' , $time, $m ) ) {
     504            return null;
     505        }
     506        return ( (int) $m[1] * 60 ) + (int) $m[2];
     507    }
     508
     509    private static function normalize_window_clock_minutes( int $open_m, int $close_m ) : array {
     510        $overnight = ( $close_m <= $open_m );
     511        if ( $overnight ) {
     512            $close_m += 1440;
     513        }
     514        return [ $open_m, $close_m, $overnight ];
     515    }
     516
     517    private static function map_time_minutes_into_window( ?int $time_m, int $open_m, int $close_m ) : ?int {
     518        if ( $time_m === null ) {
     519            return null;
     520        }
     521        if ( $close_m > 1440 && $time_m < $open_m ) {
     522            $time_m += 1440;
     523        }
     524        return $time_m;
     525    }
     526
     527    private static function get_free_day_schedule_for_date( DateTimeImmutable $date ) : array {
     528        $site_id = get_current_blog_id();
     529        $day = strtolower( $date->format( 'l' ) );
     530        return [
     531            'day' => $day,
     532            'closed' => (int) self::get_opt_with_fallback( self::OPTION_DAY_CLOSED_PREFIX . $day, 'opening_hours_day_closed_' . $day . '_' . $site_id, 0 ),
     533            'open' => self::get_time_opt_with_fallback( self::OPTION_OPEN_PREFIX . $day, 'opening_hours_open_' . $day . '_' . $site_id, '09:00' ),
     534            'close' => self::get_time_opt_with_fallback( self::OPTION_CLOSE_PREFIX . $day, 'opening_hours_close_' . $day . '_' . $site_id, '17:00' ),
     535        ];
     536    }
     537
     538    private static function build_window_from_day_schedule( DateTimeImmutable $date, array $day_schedule ) : ?array {
     539        $open_m_raw  = self::time_to_minutes( (string) ( $day_schedule['open'] ?? '' ) );
     540        $close_m_raw = self::time_to_minutes( (string) ( $day_schedule['close'] ?? '' ) );
     541        if ( $open_m_raw === null || $close_m_raw === null ) {
     542            return null;
     543        }
     544        list( $open_m, $close_m, $overnight ) = self::normalize_window_clock_minutes( $open_m_raw, $close_m_raw );
     545        $day_start = $date->setTime( 0, 0, 0 );
     546        return [
     547            'day' => (string) ( $day_schedule['day'] ?? strtolower( $date->format( 'l' ) ) ),
     548            'day_start' => $day_start,
     549            'open' => (string) ( $day_schedule['open'] ?? '09:00' ),
     550            'close' => (string) ( $day_schedule['close'] ?? '17:00' ),
     551            'open_m' => $open_m,
     552            'close_m' => $close_m,
     553            'overnight' => $overnight,
     554            'open_dt' => $day_start->modify( '+' . $open_m_raw . ' minutes' ),
     555            'close_dt' => $day_start->modify( '+' . $close_m . ' minutes' ),
     556        ];
     557    }
     558
     559    private static function get_active_free_schedule_window( DateTimeImmutable $now, string $mode_option = 'normal' ) : ?array {
     560        if ( $mode_option !== 'normal' ) {
     561            return null;
     562        }
     563        foreach ( [ -1, 0 ] as $offset ) {
     564            $date = ( $offset === 0 ) ? $now : $now->modify( $offset . ' day' );
     565            if ( ! $date instanceof DateTimeImmutable ) {
     566                continue;
     567            }
     568            $day_schedule = self::get_free_day_schedule_for_date( $date );
     569            if ( ! empty( $day_schedule['closed'] ) ) {
     570                continue;
     571            }
     572            $window = self::build_window_from_day_schedule( $date, $day_schedule );
     573            if ( $window && $now >= $window['open_dt'] && $now < $window['close_dt'] ) {
     574                return $window;
     575            }
     576        }
     577        return null;
     578    }
     579
     580    private static function get_seconds_until_next_free_open( DateTimeImmutable $now ) : ?int {
     581        $best = null;
     582        for ( $i = 0; $i <= 7; $i++ ) {
     583            $date = ( $i === 0 ) ? $now : $now->modify( '+' . $i . ' day' );
     584            if ( ! $date instanceof DateTimeImmutable ) {
     585                continue;
     586            }
     587            $day_schedule = self::get_free_day_schedule_for_date( $date );
     588            if ( ! empty( $day_schedule['closed'] ) ) {
     589                continue;
     590            }
     591            $window = self::build_window_from_day_schedule( $date, $day_schedule );
     592            if ( ! $window ) {
     593                continue;
     594            }
     595            $diff = $window['open_dt']->getTimestamp() - $now->getTimestamp();
     596            if ( $diff > 0 && ( $best === null || $diff < $best ) ) {
     597                $best = (int) $diff;
     598            }
     599        }
     600        return $best;
     601    }
     602
     603    private static function format_display_time( DateTimeImmutable $dt ) : string {
     604        $fmt = get_option( 'time_format' );
     605        if ( ! is_string( $fmt ) || $fmt === '' ) {
     606            $fmt = 'H:i';
     607        }
     608        return wp_date( $fmt, $dt->getTimestamp(), $dt->getTimezone() );
     609    }
     610
     611    public static function get_break_warning_settings() : array {
     612        return self::normalize_break_warning_config([
     613            'enabled'        => (int) get_option( self::OPTION_BREAK_WARNING_ENABLED, 0 ),
     614            'minutes'        => (int) get_option( self::OPTION_BREAK_WARNING_MINUTES, 15 ),
     615            'content'        => (string) get_option( self::OPTION_BREAK_WARNING_CONTENT, 'both' ),
     616            'display'        => (string) get_option( self::OPTION_BREAK_WARNING_DISPLAY, 'rotate' ),
     617            'rotate_seconds' => (int) get_option( self::OPTION_BREAK_WARNING_ROTATE, 4 ),
     618        ]);
     619    }
     620
     621    public static function normalize_break_warning_config( array $raw ) : array {
     622        $content = isset( $raw['content'] ) ? sanitize_key( (string) $raw['content'] ) : 'both';
     623        if ( ! in_array( $content, [ 'countdown', 'time', 'both' ], true ) ) {
     624            $content = 'both';
     625        }
     626
     627        $display = isset( $raw['display'] ) ? sanitize_key( (string) $raw['display'] ) : 'rotate';
     628        if ( ! in_array( $display, [ 'warning_only', 'rotate', 'normal_only' ], true ) ) {
     629            $display = 'rotate';
     630        }
     631
     632        $minutes = isset( $raw['minutes'] ) ? (int) $raw['minutes'] : 15;
     633        $minutes = max( 1, min( 1440, $minutes ) );
     634
     635        $rotate = isset( $raw['rotate_seconds'] ) ? (int) $raw['rotate_seconds'] : 4;
     636        $rotate = max( 1, min( 60, $rotate ) );
     637
     638        return [
     639            'enabled'        => ! empty( $raw['enabled'] ) ? 1 : 0,
     640            'minutes'        => $minutes,
     641            'content'        => $content,
     642            'display'        => $display,
     643            'rotate_seconds' => $rotate,
     644        ];
     645    }
     646
     647    private static function get_break_settings_for_day( string $day ) : array {
     648        $items = self::get_break_items_for_day( $day );
     649        $first = self::first_enabled_break_item( $items );
     650        return [
     651            'global_enabled' => (int) get_option( self::OPTION_BREAKS_ENABLED, 0 ),
     652            'items'          => $items,
     653            'enabled'        => $first['enabled'],
     654            'start'          => $first['start'],
     655            'end'            => $first['end'],
     656        ];
     657    }
     658
     659    private static function is_break_supported_mode( string $mode ) : bool {
     660        return in_array( $mode, [ 'normal', 'open_247' ], true );
     661    }
     662
     663    private static function full_day_break_window_is_valid( string $break_start, string $break_end ) : bool {
     664        $start_m = self::time_to_minutes( $break_start );
     665        $end_m   = self::time_to_minutes( $break_end );
     666        if ( $start_m === null || $end_m === null ) {
     667            return false;
     668        }
     669        return $start_m < $end_m;
     670    }
     671
     672    private static function get_open_247_break_payload( DateTimeImmutable $now ) : ?array {
     673        $day = strtolower( $now->format( 'l' ) );
     674        $break = self::get_break_settings_for_day( $day );
     675        if ( empty( $break['global_enabled'] ) ) {
     676            return null;
     677        }
     678        $day_start = $now->setTime( 0, 0, 0 );
     679        $current_m = (int) floor( ( $now->getTimestamp() - $day_start->getTimestamp() ) / 60 );
     680        return self::find_active_break_from_items( (array) ( $break['items'] ?? [] ), $current_m, 0, 1440, $day_start );
     681    }
     682
     683    private static function get_open_247_pre_break_warning_payload( DateTimeImmutable $now ) : ?array {
     684        $warning = self::get_break_warning_settings();
     685        if ( empty( $warning['enabled'] ) ) {
     686            return null;
     687        }
     688        $day = strtolower( $now->format( 'l' ) );
     689        $break = self::get_break_settings_for_day( $day );
     690        if ( empty( $break['global_enabled'] ) ) {
     691            return null;
     692        }
     693        $day_start = $now->setTime( 0, 0, 0 );
     694        $payload = self::find_upcoming_break_warning_from_items( (array) ( $break['items'] ?? [] ), (int) $warning['minutes'], $now, 0, 1440, $day_start );
     695        if ( ! is_array( $payload ) ) {
     696            return null;
     697        }
     698        $payload['warning_config'] = $warning;
     699        return $payload;
     700    }
     701
     702    private static function break_window_is_valid( bool $is_closed_today, string $open_time, string $close_time, string $break_start, string $break_end ) : bool {
     703        if ( $is_closed_today || $break_start === '' || $break_end === '' ) {
     704            return false;
     705        }
     706        $open_m  = self::time_to_minutes( $open_time );
     707        $close_m = self::time_to_minutes( $close_time );
     708        $start_m = self::time_to_minutes( $break_start );
     709        $end_m   = self::time_to_minutes( $break_end );
     710        if ( $open_m === null || $close_m === null || $start_m === null || $end_m === null ) {
     711            return false;
     712        }
     713        list( $open_m, $close_m ) = self::normalize_window_clock_minutes( $open_m, $close_m );
     714        $start_m = self::map_time_minutes_into_window( $start_m, $open_m, $close_m );
     715        $end_m   = self::map_time_minutes_into_window( $end_m, $open_m, $close_m );
     716        if ( $start_m === null || $end_m === null || $start_m >= $end_m ) {
     717            return false;
     718        }
     719        return ( $start_m >= $open_m && $end_m <= $close_m );
     720    }
     721
     722    private static function get_active_break_payload( DateTimeImmutable $now, array $window ) : ?array {
     723        $day = (string) ( $window['day'] ?? '' );
     724        if ( $day === '' ) {
     725            return null;
     726        }
     727        $break = self::get_break_settings_for_day( $day );
     728        if ( empty( $break['global_enabled'] ) ) {
     729            return null;
     730        }
     731        $base_dt   = $window['day_start'];
     732        $current_m = (int) floor( ( $now->getTimestamp() - $base_dt->getTimestamp() ) / 60 );
     733        return self::find_active_break_from_items( (array) ( $break['items'] ?? [] ), $current_m, (int) $window['open_m'], (int) $window['close_m'], $base_dt );
     734    }
     735
     736    private static function get_pre_break_warning_payload( DateTimeImmutable $now, array $window ) : ?array {
     737        $warning = self::get_break_warning_settings();
     738        if ( empty( $warning['enabled'] ) ) {
     739            return null;
     740        }
     741        $day = (string) ( $window['day'] ?? '' );
     742        if ( $day === '' ) {
     743            return null;
     744        }
     745        $break = self::get_break_settings_for_day( $day );
     746        if ( empty( $break['global_enabled'] ) ) {
     747            return null;
     748        }
     749        $payload = self::find_upcoming_break_warning_from_items( (array) ( $break['items'] ?? [] ), (int) $warning['minutes'], $now, (int) $window['open_m'], (int) $window['close_m'], $window['day_start'] );
     750        if ( ! is_array( $payload ) ) {
     751            return null;
     752        }
     753        $payload['warning_config'] = $warning;
     754        return $payload;
     755    }
     756
     757    private static function build_display_variant( string $text, ?int $countdown_seconds, string $countdown = '', int $countdown_end_ms = 0 ) : array {
     758        $countdown_seconds = is_int( $countdown_seconds ) ? max( 0, $countdown_seconds ) : null;
     759        if ( $countdown_seconds !== null && $countdown_seconds > 0 && $countdown_end_ms <= 0 ) {
     760            $countdown_end_ms = (int) round( ( microtime( true ) + $countdown_seconds ) * 1000 );
     761        }
     762        if ( $countdown_seconds !== null && $countdown_seconds > 0 && $countdown === '' ) {
     763            $countdown = self::format_countdown_mmss( $countdown_seconds );
     764        }
     765        return [
     766            'text'              => $text,
     767            'countdown'         => $countdown,
     768            'countdown_seconds' => ( $countdown_seconds !== null && $countdown_seconds > 0 ) ? $countdown_seconds : null,
     769            'countdown_end_ms'  => ( $countdown_end_ms > 0 ) ? $countdown_end_ms : 0,
     770        ];
     771    }
     772
     773    private static function get_break_warning_text( string $content_style, string $time_display ) : string {
     774        if ( 'countdown' === $content_style ) {
     775            return __( 'We are going on break in', 'statusdot' );
     776        }
     777        if ( 'time' === $content_style ) {
     778            /* translators: %s: break start time */
     779            return sprintf( __( 'We are going on break at %s', 'statusdot' ), $time_display );
     780        }
     781        /* translators: %s: break start time */
     782        return sprintf( __( 'We are going on break at %s', 'statusdot' ), $time_display ) . ' - ' . __( 'in', 'statusdot' );
     783    }
     784
     785    private static function maybe_apply_break_warning_display( array $data, array $warning_payload ) : array {
     786        $config = isset( $warning_payload['warning_config'] ) && is_array( $warning_payload['warning_config'] ) ? $warning_payload['warning_config'] : self::get_break_warning_settings();
     787        if ( empty( $config['enabled'] ) || empty( $warning_payload['start_ts'] ) ) {
     788            return $data;
     789        }
     790
     791        $warning_variant = self::build_display_variant(
     792            self::get_break_warning_text( (string) $config['content'], (string) ( $warning_payload['start_display'] ?? '' ) ),
     793            ( 'time' === (string) $config['content'] ) ? null : (int) ( $warning_payload['in_seconds'] ?? 0 ),
     794            '',
     795            ( 'time' === (string) $config['content'] ) ? 0 : ( (int) $warning_payload['start_ts'] * 1000 )
     796        );
     797
     798        if ( 'warning_only' === (string) $config['display'] ) {
     799            $data['text'] = $warning_variant['text'];
     800            $data['countdown_seconds'] = $warning_variant['countdown_seconds'];
     801            $data['countdown'] = $warning_variant['countdown'];
     802            $data['countdown_end_ms'] = $warning_variant['countdown_end_ms'];
     803            $data['display_variants'] = [ $warning_variant ];
     804            $data['display_rotation_seconds'] = 0;
     805            $data['is_break_warning'] = true;
     806            return $data;
     807        }
     808
     809        if ( 'rotate' === (string) $config['display'] ) {
     810            $normal_variant = self::build_display_variant(
     811                (string) ( $data['text'] ?? '' ),
     812                isset( $data['countdown_seconds'] ) && is_int( $data['countdown_seconds'] ) ? (int) $data['countdown_seconds'] : null,
     813                (string) ( $data['countdown'] ?? '' ),
     814                isset( $data['countdown_end_ms'] ) ? (int) $data['countdown_end_ms'] : 0
     815            );
     816            $variants = [];
     817            if ( $normal_variant['text'] !== '' || ! empty( $normal_variant['countdown_end_ms'] ) || ! empty( $normal_variant['countdown'] ) ) {
     818                $variants[] = $normal_variant;
     819            }
     820            $variants[] = $warning_variant;
     821            $data['display_variants'] = $variants;
     822            $data['display_rotation_seconds'] = (int) $config['rotate_seconds'];
     823            $data['is_break_warning'] = true;
     824        }
     825
     826        return $data;
     827    }
     828
    131829    public static function maybe_migrate_old_options() {
    132830        if (!current_user_can('manage_options')) return;
     
    172870
    173871        if ($found_old) update_option('statusdot_migrated_from_mu', 1);
     872    }
     873
     874    public static function maybe_sync_from_saved_pro_default() : void {
     875        if ( class_exists( 'StatusDot_Pro' ) ) {
     876            return;
     877        }
     878        if ( ! current_user_can( 'manage_options' ) ) {
     879            return;
     880        }
     881
     882        $raw = get_option( 'statusdot_pro_schedules', null );
     883        if ( ! is_array( $raw ) || empty( $raw['default'] ) || ! is_array( $raw['default'] ) ) {
     884            return;
     885        }
     886
     887        $payload = [
     888            'mode'           => $raw['default']['mode'] ?? 'normal',
     889            'busy_manual'    => ! empty( $raw['default']['busy_manual'] ) ? 1 : 0,
     890            'breaks_enabled' => ! empty( $raw['default']['breaks_enabled'] ) ? 1 : 0,
     891            'weekly'         => isset( $raw['default']['weekly'] ) && is_array( $raw['default']['weekly'] ) ? $raw['default']['weekly'] : [],
     892            'breaks'         => isset( $raw['default']['breaks'] ) && is_array( $raw['default']['breaks'] ) ? $raw['default']['breaks'] : [],
     893            'break_warning'  => isset( $raw['default']['break_warning'] ) && is_array( $raw['default']['break_warning'] ) ? $raw['default']['break_warning'] : [],
     894        ];
     895
     896        $pro_hash  = self::free_schedule_payload_hash( $payload );
     897        $free_hash = self::free_schedule_payload_hash();
     898        $mirror    = (string) get_option( 'statusdot_free_pro_mirror_hash', '' );
     899
     900        if ( $pro_hash !== $free_hash && $mirror === $pro_hash ) {
     901            self::save_free_schedule_payload( $payload, false );
     902            update_option( 'statusdot_free_pro_mirror_hash', $pro_hash );
     903        }
     904    }
     905
     906
     907    private static function free_idle_can_start_now( ?DateTimeImmutable $now = null ) : bool {
     908        $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wp_timezone_string() );
     909        if ( ! ( $now instanceof DateTimeImmutable ) ) {
     910            $now = new DateTimeImmutable( 'now', $tz );
     911        }
     912
     913        $mode_option = (string) get_option( self::OPTION_MODE, 'normal' );
     914        if ( $mode_option === 'closed' ) {
     915            return false;
     916        }
     917        if ( $mode_option === 'open_247' ) {
     918            return true;
     919        }
     920
     921        return is_array( self::get_active_free_schedule_window( $now, 'normal' ) );
    174922    }
    175923
     
    195943
    196944            if ( isset( $_POST['statusdot_idle_start'] ) ) {
    197                 $mins = isset( $_POST['idle_minutes'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['idle_minutes'] ) ) : 30;
    198                 $mins = max( 1, min( 1440, $mins ) ); // 1..1440
    199                 update_option( self::OPTION_IDLE_MINUTES, $mins );
    200                 update_option( self::OPTION_IDLE_UNTIL, time() + ( $mins * 60 ) );
     945                if ( self::free_idle_can_start_now() ) {
     946                    $mins = isset( $_POST['idle_minutes'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['idle_minutes'] ) ) : 30;
     947                    $mins = max( 1, min( 1440, $mins ) ); // 1..1440
     948                    update_option( self::OPTION_IDLE_MINUTES, $mins );
     949                    update_option( self::OPTION_IDLE_UNTIL, time() + ( $mins * 60 ) );
     950                } else {
     951                    $errors   = get_transient( 'statusdot_admin_errors' );
     952                    $errors   = is_array( $errors ) ? $errors : [];
     953                    $errors[] = __( 'Your store is currently closed. Idle override can only be started during opening hours.', 'statusdot' );
     954                    set_transient( 'statusdot_admin_errors', $errors, 30 );
     955                }
    201956            }
    202957
    203958
    204959            $days = self::days();
    205             $posted_day_closed  = (array) filter_input( INPUT_POST, 'day_closed', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
    206             $posted_open_hours  = (array) filter_input( INPUT_POST, 'open_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
    207             $posted_close_hours = (array) filter_input( INPUT_POST, 'close_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
    208             $posted_day_closed  = is_array( $posted_day_closed ) ? wp_unslash( $posted_day_closed ) : [];
    209             $posted_open_hours  = is_array( $posted_open_hours ) ? wp_unslash( $posted_open_hours ) : [];
    210             $posted_close_hours = is_array( $posted_close_hours ) ? wp_unslash( $posted_close_hours ) : [];
     960            $posted_day_closed   = (array) filter_input( INPUT_POST, 'day_closed', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
     961            $posted_open_hours   = (array) filter_input( INPUT_POST, 'open_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
     962            $posted_close_hours  = (array) filter_input( INPUT_POST, 'close_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
     963            $posted_break_enable = (array) filter_input( INPUT_POST, 'break_enabled', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
     964            $posted_break_start  = (array) filter_input( INPUT_POST, 'break_start', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
     965            $posted_break_end    = (array) filter_input( INPUT_POST, 'break_end', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY );
     966            $posted_day_closed   = is_array( $posted_day_closed ) ? wp_unslash( $posted_day_closed ) : [];
     967            $posted_open_hours   = is_array( $posted_open_hours ) ? wp_unslash( $posted_open_hours ) : [];
     968            $posted_close_hours  = is_array( $posted_close_hours ) ? wp_unslash( $posted_close_hours ) : [];
     969            $posted_break_enable = is_array( $posted_break_enable ) ? wp_unslash( $posted_break_enable ) : [];
     970            $posted_break_start  = is_array( $posted_break_start ) ? wp_unslash( $posted_break_start ) : [];
     971            $posted_break_end    = is_array( $posted_break_end ) ? wp_unslash( $posted_break_end ) : [];
     972            $break_errors = [];
     973            $posted_status_mode = isset( $_POST['status_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['status_mode'] ) ) : (string) get_option( self::OPTION_MODE, 'normal' );
     974
     975            update_option( self::OPTION_BREAKS_ENABLED, isset( $_POST['breaks_enabled'] ) ? 1 : 0 );
     976            update_option( self::OPTION_BREAK_WARNING_ENABLED, isset( $_POST['break_warning_enabled'] ) ? 1 : 0 );
     977            update_option( self::OPTION_BREAK_WARNING_MINUTES, max( 1, min( 1440, isset( $_POST['break_warning_minutes'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['break_warning_minutes'] ) ) : 15 ) ) );
     978            update_option( self::OPTION_BREAK_WARNING_CONTENT, self::normalize_break_warning_config([
     979                'content' => isset( $_POST['break_warning_content'] ) ? sanitize_text_field( wp_unslash( $_POST['break_warning_content'] ) ) : 'both',
     980            ])['content'] );
     981            update_option( self::OPTION_BREAK_WARNING_DISPLAY, self::normalize_break_warning_config([
     982                'display' => isset( $_POST['break_warning_display'] ) ? sanitize_text_field( wp_unslash( $_POST['break_warning_display'] ) ) : 'rotate',
     983            ])['display'] );
     984            update_option( self::OPTION_BREAK_WARNING_ROTATE, max( 1, min( 60, isset( $_POST['break_warning_rotate'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['break_warning_rotate'] ) ) : 4 ) ) );
    211985
    212986            foreach ($days as $day) {
     
    214988                update_option(self::OPTION_DAY_CLOSED_PREFIX . $day, $is_closed);
    215989
    216                 // Sanitize each element explicitly so Plugin Check recognises the input is sanitized.
    217                 $open_hour_raw  = isset( $posted_open_hours[ $day ] ) ? sanitize_text_field( (string) $posted_open_hours[ $day ] ) : '09:00';
    218                 $close_hour_raw = isset( $posted_close_hours[ $day ] ) ? sanitize_text_field( (string) $posted_close_hours[ $day ] ) : '17:00';
    219                 update_option( self::OPTION_OPEN_PREFIX . $day, self::sanitize_time_field( $open_hour_raw, '09:00' ) );
    220                 update_option( self::OPTION_CLOSE_PREFIX . $day, self::sanitize_time_field( $close_hour_raw, '17:00' ) );
     990                $open_hour_raw  = isset( $posted_open_hours[ $day ] ) ? sanitize_text_field( (string) $posted_open_hours[ $day ] ) : '09:00';
     991                $close_hour_raw = isset( $posted_close_hours[ $day ] ) ? sanitize_text_field( (string) $posted_close_hours[ $day ] ) : '17:00';
     992                $open_time  = self::sanitize_time_field( $open_hour_raw, '09:00' );
     993                $close_time = self::sanitize_time_field( $close_hour_raw, '17:00' );
     994                update_option( self::OPTION_OPEN_PREFIX . $day, $open_time );
     995                update_option( self::OPTION_CLOSE_PREFIX . $day, $close_time );
     996
     997                $enabled_map = isset( $posted_break_enable[ $day ] ) && is_array( $posted_break_enable[ $day ] ) ? $posted_break_enable[ $day ] : [];
     998                $start_map   = isset( $posted_break_start[ $day ] ) && is_array( $posted_break_start[ $day ] ) ? $posted_break_start[ $day ] : [];
     999                $end_map     = isset( $posted_break_end[ $day ] ) && is_array( $posted_break_end[ $day ] ) ? $posted_break_end[ $day ] : [];
     1000                $slot_keys   = array_unique( array_merge( array_keys( $enabled_map ), array_keys( $start_map ), array_keys( $end_map ) ) );
     1001                natcasesort( $slot_keys );
     1002                $day_break_items = [];
     1003                foreach ( $slot_keys as $slot_key ) {
     1004                    $enabled = isset( $enabled_map[ $slot_key ] ) ? 1 : 0;
     1005                    $start_raw = isset( $start_map[ $slot_key ] ) ? sanitize_text_field( (string) $start_map[ $slot_key ] ) : '';
     1006                    $end_raw   = isset( $end_map[ $slot_key ] ) ? sanitize_text_field( (string) $end_map[ $slot_key ] ) : '';
     1007                    $day_break_items[] = [
     1008                        'enabled' => $enabled,
     1009                        'start'   => self::sanitize_optional_clock_time( $start_raw ),
     1010                        'end'     => self::sanitize_optional_clock_time( $end_raw ),
     1011                    ];
     1012                }
     1013                if ( empty( $day_break_items ) ) {
     1014                    $day_break_items[] = [ 'enabled' => 0, 'start' => '', 'end' => '' ];
     1015                }
     1016
     1017                if ( isset( $_POST['breaks_enabled'] ) ) {
     1018                    $slot_errors = self::validate_break_items_config( $day_break_items, (bool) $is_closed, $open_time, $close_time, $posted_status_mode === 'open_247' ? 'open_247' : 'normal' );
     1019                    foreach ( $slot_errors as $slot => $message ) {
     1020                        $break_errors[] = sprintf(
     1021                            /* translators: 1: weekday name, 2: break slot number, 3: validation error */
     1022                            __( '%1$s break %2$d: %3$s', 'statusdot' ),
     1023                            ucfirst( $day ),
     1024                            (int) $slot + 1,
     1025                            $message
     1026                        );
     1027                    }
     1028                }
     1029
     1030                $normalized_breaks = self::normalize_break_items( [ 'items' => $day_break_items ] );
     1031
     1032                $first_break = self::first_enabled_break_item( $normalized_breaks['items'] );
     1033                update_option( self::OPTION_BREAKS_PREFIX . $day, $normalized_breaks );
     1034                update_option( self::OPTION_BREAK_ENABLED_PREFIX . $day, $first_break['enabled'] );
     1035                update_option( self::OPTION_BREAK_START_PREFIX . $day, $first_break['start'] );
     1036                update_option( self::OPTION_BREAK_END_PREFIX . $day, $first_break['end'] );
     1037            }
     1038
     1039            if ( ! empty( $break_errors ) ) {
     1040                set_transient( 'statusdot_admin_errors', $break_errors, 60 );
     1041            } else {
     1042                delete_transient( 'statusdot_admin_errors' );
    2211043            }
    2221044
     
    2551077            }
    2561078            update_option( self::OPTION_SEPARATOR_MODE, $separator_mode );
     1079            update_option( self::OPTION_LIGHT_TEXT, isset( $_POST['light_text'] ) ? 1 : 0 );
     1080
     1081                // Mark the free payload as the latest intentional state and keep the stored Pro default schedule mirrored.
     1082                $free_payload_now = self::get_free_schedule_payload();
     1083                self::maybe_sync_saved_pro_default_from_free_payload( $free_payload_now );
     1084                update_option( 'statusdot_free_pro_mirror_hash', self::free_schedule_payload_hash( $free_payload_now ) );
    2571085
    2581086                // Bump settings revision so frontend AJAX requests bypass any caches immediately.
     
    2671095
    2681096        // Determine the effective status right now (used for admin labels).
    269         $idle_until  = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );
    270         $idle_active = ( $idle_until > time() );
     1097        $idle_until_raw  = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );
     1098        $idle_active_raw = ( $idle_until_raw > time() );
    2711099
    2721100        $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wp_timezone_string() );
     
    2781106        $close_time_today = self::sanitize_clock_time( (string) get_option( self::OPTION_CLOSE_PREFIX . $today_key, '17:00' ), '17:00' );
    2791107
    280         $is_open_now = false;
    281         if ( $mode === 'open_247' ) {
    282             $is_open_now = true;
    283         } elseif ( $mode !== 'normal' ) {
    284             $is_open_now = false;
    285         } elseif ( $is_closed_today ) {
    286             $is_open_now = false;
    287         } else {
    288             try {
    289                 list( $oh, $om ) = array_map( 'intval', explode( ':', $open_time_today ) );
    290                 list( $ch, $cm ) = array_map( 'intval', explode( ':', $close_time_today ) );
    291                 $open_dt  = ( clone $now_dt )->setTime( $oh, $om, 0 );
    292                 $close_dt = ( clone $now_dt )->setTime( $ch, $cm, 0 );
    293                 $is_open_now = ( $now_dt >= $open_dt && $now_dt < $close_dt );
    294             } catch ( Exception $e ) {
    295                 $is_open_now = false;
    296             }
    297         }
     1108        $current_window = ( $mode === 'normal' ) ? self::get_active_free_schedule_window( DateTimeImmutable::createFromMutable( $now_dt ), 'normal' ) : null;
     1109        $is_open_now = ( $mode === 'open_247' ) ? true : is_array( $current_window );
     1110        $idle_can_start = ( $mode !== 'closed' ) && ( $mode === 'open_247' || $is_open_now );
     1111        $idle_active = ( $idle_active_raw && $idle_can_start );
     1112
     1113        $break_payload = ( $mode === 'normal' && $is_open_now && ! $idle_active && is_array( $current_window ) )
     1114            ? self::get_active_break_payload( DateTimeImmutable::createFromMutable( $now_dt ), $current_window )
     1115            : ( ( $mode === 'open_247' && ! $idle_active )
     1116                ? self::get_open_247_break_payload( DateTimeImmutable::createFromMutable( $now_dt ) )
     1117                : null );
     1118        $break_active = is_array( $break_payload ) && ! empty( $break_payload['end_ts'] );
    2981119
    2991120        $effective = 'closed';
     
    3131134            } elseif ( $idle_active ) {
    3141135                $effective = 'idle';
     1136            } elseif ( $break_active ) {
     1137                $effective = 'break';
    3151138            } elseif ( $busy_mode ) {
    3161139                $effective = 'busy';
     
    3271150        } elseif ( $effective === 'idle' ) {
    3281151            $status_state_html = '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Idle', 'statusdot' ) . ')</span>';
     1152        } elseif ( $effective === 'break' ) {
     1153            $status_state_html = '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Break', 'statusdot' ) . ')</span>';
    3291154        } else {
    3301155            $status_state_html = '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Closed', 'statusdot' ) . ')</span>';
     
    3371162        <div class="wrap">
    3381163            <h1><?php echo esc_html__('StatusDot', 'statusdot'); ?></h1>
     1164
     1165            <?php
     1166            $statusdot_admin_errors = get_transient( 'statusdot_admin_errors' );
     1167            if ( is_array( $statusdot_admin_errors ) && ! empty( $statusdot_admin_errors ) ) :
     1168                delete_transient( 'statusdot_admin_errors' );
     1169            ?>
     1170                <div class="notice notice-error"><p><?php echo esc_html( implode( ' ', $statusdot_admin_errors ) ); ?></p></div>
     1171            <?php endif; ?>
    3391172
    3401173            <?php do_action( 'statusdot_admin_notices' ); ?>
     
    3551188                    <a href="#statusdot-tab-advanced" class="nav-tab nav-tab-active" data-statusdot-tab="advanced"><?php echo esc_html__( 'StatusDot Pro', 'statusdot' ); ?></a>
    3561189                    <a href="#statusdot-tab-pro" class="nav-tab" data-statusdot-tab="pro"><?php echo esc_html__( 'Pro Features', 'statusdot' ); ?></a>
     1190                    <a href="#statusdot-tab-analytics" class="nav-tab" data-statusdot-tab="analytics"><?php echo esc_html__( 'Analytics', 'statusdot' ); ?></a>
    3571191                </h2>
    3581192
     
    3631197                <div id="statusdot-tab-pro" class="statusdot-tab-panel" style="display:none;">
    3641198                    <?php do_action( 'statusdot_admin_tab_profeatures' ); ?>
     1199                </div>
     1200
     1201                <div id="statusdot-tab-analytics" class="statusdot-tab-panel" style="display:none;">
     1202                    <?php do_action( 'statusdot_admin_tab_analytics' ); ?>
    3651203                </div>
    3661204
     
    3961234                        $opt_time_busy   = (int) get_option( self::OPTION_SHOW_TIME_BUSY, 1 );
    3971235                        $opt_time_closed = (int) get_option( self::OPTION_SHOW_TIME_CLOSED, 1 );
     1236                        $opt_light_text  = (int) get_option( self::OPTION_LIGHT_TEXT, 0 );
    3981237                        $opt_separator   = (string) get_option( self::OPTION_SEPARATOR_MODE, '-' );
    3991238                        if ( $opt_separator === 'none' ) {
     
    4321271                                    </td>
    4331272                                </tr>
     1273                                <tr>
     1274                                    <th scope="row"><?php echo esc_html__( 'Text color', 'statusdot' ); ?></th>
     1275                                    <td>
     1276                                        <label><input type="checkbox" name="light_text" <?php checked( $opt_light_text, 1 ); ?> /> <?php echo esc_html__( 'Use light text (white)', 'statusdot' ); ?></label>
     1277                                        <p class="description"><?php echo esc_html__( 'Makes the shortcode text and countdown white. Helpful on dark backgrounds.', 'statusdot' ); ?></p>
     1278                                    </td>
     1279                                </tr>
    4341280                            </tbody>
    4351281                        </table>
     
    4391285
    4401286
    441                     <h2><?php echo wp_kses_post( esc_html__( 'Status Mode', 'statusdot' ) . ' ' . $status_state_html ); ?></h2>
     1287                    <h2><?php echo esc_html__( 'Status Mode', 'statusdot' ); ?> <span class="statusdot-live-statusmode-state"><?php echo wp_kses_post( $status_state_html ); ?></span></h2>
    4421288                <label>
    4431289                    <input type="radio" name="status_mode" value="normal" <?php checked($mode, 'normal'); ?>>
     
    4651311
    4661312<?php
    467   $idle_until  = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );
    4681313  $idle_mins   = (int) get_option( self::OPTION_IDLE_MINUTES, 30 );
    469   $idle_active = ( $idle_until > time() );
    4701314  $idle_state  = $idle_active
    4711315    ? '<span style="color:#00a32a;font-weight:600;">(' . esc_html__( 'Active', 'statusdot' ) . ')</span>'
    4721316    : '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Inactive', 'statusdot' ) . ')</span>';
    473   $weekly_active = ( $mode === 'normal' );
    474   $weekly_state  = ( $weekly_active && $idle_active )
     1317  $weekly_active  = self::is_break_supported_mode( (string) $mode );
     1318  $breaks_enabled = (int) get_option( self::OPTION_BREAKS_ENABLED, 0 );
     1319  $weekly_state   = ( $weekly_active && $idle_active )
    4751320    ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Idle override is active', 'statusdot' ) . ')</span>'
    476     : ( ( $weekly_active && $busy_mode && $is_open_now )
    477         ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Active - Busy Mode Enabled', 'statusdot' ) . ')</span>'
    478         : ( $weekly_active
    479             ? '<span style="color:#00a32a;font-weight:600;">(' . esc_html__( 'Active', 'statusdot' ) . ')</span>'
    480             : '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Inactive', 'statusdot' ) . ')</span>'
     1321    : ( ( $weekly_active && ! empty( $break_active ) )
     1322        ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Break time is active', 'statusdot' ) . ')</span>'
     1323        : ( ( $weekly_active && $busy_mode && $is_open_now )
     1324            ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Active - Busy Mode Enabled', 'statusdot' ) . ')</span>'
     1325            : ( $weekly_active
     1326                ? '<span style="color:#00a32a;font-weight:600;">(' . esc_html__( 'Active', 'statusdot' ) . ')</span>'
     1327                : '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Inactive', 'statusdot' ) . ')</span>'
     1328              )
    4811329          )
    4821330      );
     
    4851333<div class="statusdot-card statusdot-idle-card" style="margin-top:14px;">
    4861334  <div class="statusdot-card-head">
    487         <h2><?php echo wp_kses_post( esc_html__( 'Idle override (Back in...)', 'statusdot' ) . ' ' . $idle_state ); ?></h2>
     1335        <h2><?php echo esc_html__( 'Idle override (Back in...)', 'statusdot' ); ?> <span class="statusdot-idle-live-heading-state"><?php echo wp_kses_post( $idle_state ); ?></span></h2>
    4881336  </div>
    4891337  <div class="statusdot-card-body">
     
    5041352          <td>
    5051353            <?php if ( $idle_active ) : ?>
    506               <span class="statusdot-pill statusdot-pill--active"><?php echo esc_html__( 'Idle is active', 'statusdot' ); ?></span>
     1354              <span class="statusdot-pill statusdot-pill--active statusdot-idle-live-pill"><?php echo esc_html__( 'Idle is active', 'statusdot' ); ?></span>
    5071355            <?php else : ?>
    508               <span class="statusdot-pill"><?php echo esc_html__( 'Idle is not active', 'statusdot' ); ?></span>
     1356              <span class="statusdot-pill statusdot-idle-live-pill"><?php echo esc_html__( 'Idle is not active', 'statusdot' ); ?></span>
    5091357            <?php endif; ?>
    5101358          </td>
     
    5141362
    5151363    <p style="margin-top:10px;">
    516       <button type="submit" name="statusdot_idle_start" class="button button-primary"><?php echo esc_html__( 'Start Idle', 'statusdot' ); ?></button>
     1364      <button type="submit" name="statusdot_idle_start" class="button button-primary statusdot-idle-start-button"<?php disabled( ! $idle_can_start ); ?>><?php echo esc_html__( 'Start Idle', 'statusdot' ); ?></button>
    5171365      <button type="submit" name="statusdot_idle_stop" class="button"><?php echo esc_html__( 'Stop Idle', 'statusdot' ); ?></button>
    5181366    </p>
     1367    <p class="description statusdot-idle-start-help"<?php echo $idle_can_start ? ' style="display:none;"' : ''; ?>><?php echo esc_html__( 'Your store is currently closed. Idle override can only be started during opening hours.', 'statusdot' ); ?></p>
    5191368  </div>
    5201369</div>
    5211370<div class="statusdot-card statusdot-card--weekly">
    5221371  <div class="statusdot-card-head">
    523         <h2><?php echo wp_kses_post( esc_html__('Weekly Schedule', 'statusdot') . ' ' . $weekly_state ); ?></h2>
     1372        <h2><?php echo esc_html__('Weekly Schedule', 'statusdot'); ?> <span class="statusdot-live-weekly-state"><?php echo wp_kses_post( $weekly_state ); ?></span></h2>
    5241373  </div>
    5251374  <div class="statusdot-card-body">
     1375    <p style="margin:0 0 12px;">
     1376      <label>
     1377        <input type="checkbox" id="statusdot-breaks-enabled" name="breaks_enabled" value="1" <?php checked( $breaks_enabled, 1 ); ?>>
     1378        <?php echo esc_html__( 'Enable breaks', 'statusdot' ); ?>
     1379      </label>
     1380      <span class="description" style="display:block; margin-top:6px;"><?php echo esc_html__( 'Breaks apply while Weekly Schedule or Open 24/7 is active. In Weekly Schedule they must stay inside that day\'s opening hours; in Open 24/7 they can be set anywhere within that day.', 'statusdot' ); ?></span>
     1381      <span class="description" style="display:block; margin-top:4px;"><?php echo esc_html__( 'Tip: if the close time is earlier than the open time, StatusDot treats it as an overnight shift into the next day.', 'statusdot' ); ?></span>
     1382    </p>
     1383    <?php $break_warning_cfg = self::get_break_warning_settings(); ?>
     1384    <div class="statusdot-break-warning-settings" style="margin:0 0 14px; padding:12px; border:1px solid #e5e5e5; border-radius:8px; background:#fff;">
     1385      <p style="margin:0 0 10px;">
     1386        <label>
     1387          <input type="checkbox" id="statusdot-break-warning-enabled" name="break_warning_enabled" value="1" <?php checked( ! empty( $break_warning_cfg['enabled'] ), true ); ?>>
     1388          <?php echo esc_html__( 'Enable pre-break warning', 'statusdot' ); ?>
     1389        </label>
     1390        <span class="description" style="display:block; margin-top:6px;"><?php echo esc_html__( 'Only applies when Weekly Schedule or Open 24/7 is active and a valid break is coming up.', 'statusdot' ); ?></span>
     1391      </p>
     1392      <div class="statusdot-break-warning-grid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;">
     1393        <p style="margin:0;">
     1394          <label for="statusdot-break-warning-minutes"><strong><?php echo esc_html__( 'Warn before break (minutes)', 'statusdot' ); ?></strong></label><br>
     1395          <input type="number" min="1" max="1440" class="small-text" id="statusdot-break-warning-minutes" name="break_warning_minutes" value="<?php echo esc_attr( (int) $break_warning_cfg['minutes'] ); ?>">
     1396        </p>
     1397        <p style="margin:0;">
     1398          <label for="statusdot-break-warning-content"><strong><?php echo esc_html__( 'Warning content', 'statusdot' ); ?></strong></label><br>
     1399          <select id="statusdot-break-warning-content" name="break_warning_content">
     1400            <option value="countdown" <?php selected( $break_warning_cfg['content'], 'countdown' ); ?>><?php echo esc_html__( 'Countdown only', 'statusdot' ); ?></option>
     1401            <option value="time" <?php selected( $break_warning_cfg['content'], 'time' ); ?>><?php echo esc_html__( 'Start time only', 'statusdot' ); ?></option>
     1402            <option value="both" <?php selected( $break_warning_cfg['content'], 'both' ); ?>><?php echo esc_html__( 'Both', 'statusdot' ); ?></option>
     1403          </select>
     1404        </p>
     1405        <p style="margin:0;">
     1406          <label for="statusdot-break-warning-display"><strong><?php echo esc_html__( 'Display behavior', 'statusdot' ); ?></strong></label><br>
     1407          <select id="statusdot-break-warning-display" name="break_warning_display">
     1408            <option value="warning_only" <?php selected( $break_warning_cfg['display'], 'warning_only' ); ?>><?php echo esc_html__( 'Show warning only', 'statusdot' ); ?></option>
     1409            <option value="rotate" <?php selected( $break_warning_cfg['display'], 'rotate' ); ?>><?php echo esc_html__( 'Rotate warning with normal open text', 'statusdot' ); ?></option>
     1410            <option value="normal_only" <?php selected( $break_warning_cfg['display'], 'normal_only' ); ?>><?php echo esc_html__( 'Show only normal open text', 'statusdot' ); ?></option>
     1411          </select>
     1412        </p>
     1413        <p style="margin:0;">
     1414          <label for="statusdot-break-warning-rotate"><strong><?php echo esc_html__( 'Rotate every (seconds)', 'statusdot' ); ?></strong></label><br>
     1415          <input type="number" min="1" max="60" class="small-text" id="statusdot-break-warning-rotate" name="break_warning_rotate" value="<?php echo esc_attr( (int) $break_warning_cfg['rotate_seconds'] ); ?>">
     1416        </p>
     1417      </div>
     1418    </div>
    5261419    <div class="statusdot-tablewrap">
    5271420<table id="statusdot-free-weekly-table" class="statusdot-weekly-table">
     
    5321425                            <th><?php echo esc_html__('Close Hour', 'statusdot'); ?></th>
    5331426                            <th><?php echo esc_html__('Closed?', 'statusdot'); ?></th>
     1427                            <th><?php echo esc_html__('Break windows', 'statusdot'); ?></th>
    5341428                        </tr>
    5351429                    </thead>
    5361430                    <tbody>
    5371431                        <?php foreach ($days as $day): ?>
     1432                            <?php $break_items_day = self::get_break_items_for_day( $day ); ?>
    5381433                            <tr>
    5391434                                <td><?php echo esc_html(ucfirst($day)); ?></td>
     
    5541449                                        <?php checked((int) get_option(self::OPTION_DAY_CLOSED_PREFIX . $day, 0), 1); ?>>
    5551450                                </td>
     1451                                <td>
     1452                                    <div class="statusdot-break-slots" data-break-max="<?php echo esc_attr( self::break_slot_count() ); ?>">
     1453                                      <div class="statusdot-break-slot-list">
     1454                                      <?php foreach ( $break_items_day as $slot_index => $break_item ) : ?>
     1455                                        <div class="statusdot-break-slot" data-break-slot="<?php echo esc_attr( $slot_index ); ?>">
     1456                                          <label class="statusdot-break-slot__toggle">
     1457                                            <input type="checkbox" class="statusdot-break-enabled"
     1458                                              name="break_enabled[<?php echo esc_attr($day); ?>][<?php echo esc_attr( $slot_index ); ?>]"
     1459                                              value="1"
     1460                                              <?php checked( ! empty( $break_item['enabled'] ), true ); ?>>
     1461                                            <?php /* translators: %d: break slot number. */ ?>
     1462                                            <span class="statusdot-break-slot-label"><?php echo esc_html( sprintf( __( 'Break %d', 'statusdot' ), $slot_index + 1 ) ); ?></span>
     1463                                          </label>
     1464                                          <input type="time" step="60" class="statusdot-time statusdot-break-time"
     1465                                              name="break_start[<?php echo esc_attr($day); ?>][<?php echo esc_attr( $slot_index ); ?>]"
     1466                                              value="<?php echo esc_attr( self::sanitize_optional_clock_time( $break_item['start'] ?? '' ) ); ?>">
     1467                                          <span class="statusdot-break-slot__dash">&ndash;</span>
     1468                                          <input type="time" step="60" class="statusdot-time statusdot-break-time"
     1469                                              name="break_end[<?php echo esc_attr($day); ?>][<?php echo esc_attr( $slot_index ); ?>]"
     1470                                              value="<?php echo esc_attr( self::sanitize_optional_clock_time( $break_item['end'] ?? '' ) ); ?>">
     1471                                          <button type="button" class="button-link-delete statusdot-break-remove"><?php echo esc_html__( 'Remove', 'statusdot' ); ?></button>
     1472                                        </div>
     1473                                      <?php endforeach; ?>
     1474                                      </div>
     1475                                      <div class="statusdot-break-slot-actions">
     1476                                        <button type="button" class="button button-secondary statusdot-break-add"><?php echo esc_html__( 'Add break', 'statusdot' ); ?></button>
     1477                                      </div>
     1478                                      <div class="statusdot-break-error-msg" style="display:none;"></div>
     1479                                      <template class="statusdot-break-slot-template">
     1480                                        <div class="statusdot-break-slot" data-break-slot="__INDEX__">
     1481                                          <label class="statusdot-break-slot__toggle">
     1482                                            <input type="checkbox" class="statusdot-break-enabled" name="break_enabled[<?php echo esc_attr($day); ?>][__INDEX__]" value="1">
     1483                                            <span class="statusdot-break-slot-label"><?php echo esc_html__( 'Break', 'statusdot' ); ?> __NUMBER__</span>
     1484                                          </label>
     1485                                          <input type="time" step="60" class="statusdot-time statusdot-break-time" name="break_start[<?php echo esc_attr($day); ?>][__INDEX__]" value="">
     1486                                          <span class="statusdot-break-slot__dash">&ndash;</span>
     1487                                          <input type="time" step="60" class="statusdot-time statusdot-break-time" name="break_end[<?php echo esc_attr($day); ?>][__INDEX__]" value="">
     1488                                          <button type="button" class="button-link-delete statusdot-break-remove"><?php echo esc_html__( 'Remove', 'statusdot' ); ?></button>
     1489                                        </div>
     1490                                      </template>
     1491                                    </div>
     1492                                  </td>
    5561493                            </tr>
    5571494                        <?php endforeach; ?>
     
    6001537                                    $sta_rows = [
    6011538                                        [ 'label' => __( 'Single weekly schedule', 'statusdot' ), 'free' => true,  'pro' => true ],
     1539                                        [ 'label' => __( 'Status text and countdown', 'statusdot' ), 'free' => true,  'pro' => true ],
     1540                                        [ 'label' => __( 'Idle override (Back in...)', 'statusdot' ), 'free' => true,  'pro' => true ],
     1541                                        [ 'label' => __( 'Break time mode', 'statusdot' ), 'free' => true, 'pro' => true ],
     1542                                        [ 'label' => __( 'Warn before break', 'statusdot' ), 'free' => true, 'pro' => true ],
     1543                                        [ 'label' => __( 'Break warning content styles', 'statusdot' ), 'free' => true, 'pro' => true ],
     1544                                        [ 'label' => __( 'Break warning display modes', 'statusdot' ), 'free' => true, 'pro' => true ],
     1545                                        [ 'label' => __( 'Break warning rotation timing', 'statusdot' ), 'free' => true, 'pro' => true ],
     1546                                        [ 'label' => __( 'Separator choice', 'statusdot' ), 'free' => true, 'pro' => true ],
    6021547                                        [ 'label' => __( 'Multiple schedules and locations', 'statusdot' ), 'free' => false, 'pro' => true ],
    6031548                                        [ 'label' => __( 'Manual override per schedule', 'statusdot' ), 'free' => false, 'pro' => true ],
     
    6051550                                        [ 'label' => __( 'Exceptions and one off overrides', 'statusdot' ), 'free' => false, 'pro' => true ],
    6061551                                        [ 'label' => __( 'Busy windows time ranges', 'statusdot' ), 'free' => false, 'pro' => true ],
    607                                         [ 'label' => __( 'Status text and countdown', 'statusdot' ), 'free' => true,  'pro' => true ],
    608                                         [ 'label' => __( 'Idle override (Back in...)', 'statusdot' ), 'free' => true,  'pro' => true ],
     1552                                        [ 'label' => __( 'Custom break warning text', 'statusdot' ), 'free' => false, 'pro' => true ],
    6091553                                        [ 'label' => __( 'Customizable status text and countdown', 'statusdot' ), 'free' => false, 'pro' => true ],
     1554                                        [ 'label' => __( 'Conditional display rules', 'statusdot' ), 'free' => false, 'pro' => true ],
     1555                                        [ 'label' => __( 'Schedule analytics dashboard', 'statusdot' ), 'free' => false, 'pro' => true ],
     1556                                        [ 'label' => __( 'Live activity log', 'statusdot' ), 'free' => false, 'pro' => true ],
     1557                                        [ 'label' => __( 'Mail center and report emails', 'statusdot' ), 'free' => false, 'pro' => true ],
    6101558                                        [ 'label' => __( 'Dot size and gap control', 'statusdot' ), 'free' => false, 'pro' => true ],
    6111559                                        [ 'label' => __( 'Custom colors and gradients', 'statusdot' ), 'free' => false, 'pro' => true ],
    612                                         [ 'label' => __( 'Separator choice', 'statusdot' ), 'free' => true, 'pro' => true ],
    6131560                                        [ 'label' => __( 'Border control', 'statusdot' ), 'free' => false, 'pro' => true ],
    6141561                                        [ 'label' => __( 'Pulse animation styles', 'statusdot' ), 'free' => false, 'pro' => true ],
     
    6911638        );
    6921639
    693         $idle_until = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );
    694         $idle_active = ( $idle_until > time() );
    695 
    696         // 1) Force closed always wins
    697         if ($mode_option === 'closed') {
    698             $icon = 'red-dot.svg';
    699             $color = 'red-dot';
    700         } else {
    701 
    702             // Determine if we're open right now
    703             if ($mode_option === 'open_247') {
    704                 $is_open_now = true;
    705             } elseif ($is_closed_today) {
    706                 $is_open_now = false;
    707             } else {
    708                 try {
    709                     $open_dt  = $now->setTime( $open_hour, $open_min, 0 );
    710                     $close_dt = $now->setTime( $close_hour, $close_min, 0 );
    711                 } catch ( Exception $e ) {
    712                     $open_dt = null;
    713                     $close_dt = null;
    714                 }
    715                 if ( $open_dt && $close_dt ) {
    716                     $is_open_now = ( $now >= $open_dt && $now < $close_dt );
    717                 } else {
    718                     $is_open_now = false;
    719                 }
    720             }
    721 
    722             // 2) Busy overlaps only when allowed:
    723             // - open_247 => busy can override anytime
    724             // - normal => busy only when currently open
    725             if ($busy_mode && ($mode_option === 'open_247' || $is_open_now)) {
    726                 $icon = 'orange-dot.svg';
    727                 $color = 'orange-dot';
    728             } else {
    729                 // 3) Base status rules
    730                 if ($mode_option === 'open_247') {
    731                     $icon = 'green-dot.svg';
    732                     $color = 'green-dot';
    733                 } elseif ($is_open_now) {
    734                     $icon = 'green-dot.svg';
    735                     $color = 'green-dot';
    736                 } else {
    737                     $icon = 'red-dot.svg';
    738                     $color = 'red-dot';
    739                 }
    740             }
    741         }
    742 
    743         $icon_url = STATUSDOT_PLUGIN_URL . 'assets/icons/' . $icon;
    744 
    745         // Semantic status used by the frontend (open, busy, closed)
     1640        $idle_until_raw  = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );
     1641
     1642        $current_window = ( $mode_option === 'normal' ) ? self::get_active_free_schedule_window( $now, 'normal' ) : null;
     1643        $is_open_now = ( $mode_option === 'open_247' ) ? true : is_array( $current_window );
     1644        $idle_active = ( $idle_until_raw > time() ) && ( $mode_option !== 'closed' ) && ( $mode_option === 'open_247' || $is_open_now );
     1645        $idle_until  = $idle_active ? $idle_until_raw : 0;
     1646
     1647        $break_payload = ( $mode_option === 'normal' && $is_open_now && ! $idle_active && is_array( $current_window ) )
     1648            ? self::get_active_break_payload( $now, $current_window )
     1649            : ( ( $mode_option === 'open_247' && ! $idle_active )
     1650                ? self::get_open_247_break_payload( $now )
     1651                : null );
     1652        $break_active = is_array( $break_payload ) && ! empty( $break_payload['end_ts'] );
     1653        $break_warning_payload = ( $mode_option === 'normal' && $is_open_now && ! $idle_active && ! $break_active && is_array( $current_window ) )
     1654            ? self::get_pre_break_warning_payload( $now, $current_window )
     1655            : ( ( $mode_option === 'open_247' && ! $idle_active && ! $break_active )
     1656                ? self::get_open_247_pre_break_warning_payload( $now )
     1657                : null );
     1658
     1659        // Semantic status used by the frontend (open, busy, closed, idle/break).
    7461660        if ( $mode_option === 'closed' ) {
    7471661            $mode = 'closed';
     1662            $color = 'red-dot';
    7481663        } elseif ( ! empty( $idle_active ) ) {
    749             $icon  = 'orange-dot.svg';
     1664            $mode = 'idle';
    7501665            $color = 'orange-dot';
    751             $mode  = 'idle';
     1666        } elseif ( $break_active ) {
     1667            $mode = 'idle';
     1668            $color = 'orange-dot';
    7521669        } elseif ( $busy_mode && ( $mode_option === 'open_247' || ! empty( $is_open_now ) ) ) {
    7531670            $mode = 'busy';
     1671            $color = 'orange-dot';
    7541672        } elseif ( $mode_option === 'open_247' || ! empty( $is_open_now ) ) {
    7551673            $mode = 'open';
     1674            $color = 'green-dot';
    7561675        } else {
    7571676            $mode = 'closed';
    758         }
     1677            $color = 'red-dot';
     1678        }
     1679
     1680        $icon = ( $color === 'green-dot' ) ? 'green-dot.svg' : ( $color === 'red-dot' ? 'red-dot.svg' : 'orange-dot.svg' );
     1681        $icon_url = STATUSDOT_PLUGIN_URL . 'assets/icons/' . $icon;
     1682        $effective_idle_until = $break_active ? (int) $break_payload['end_ts'] : $idle_until;
     1683        $is_break = ( $break_active && ! $idle_active );
    7591684
    7601685        $data = [
     
    7631688            'status' => ( $mode === 'idle' ? 'busy' : $mode ), // open|busy|closed
    7641689            // Semantic mode (open|busy|closed|idle) used by the frontend for small display tweaks.
    765             'status_semantic' => $mode,
    766             'label'  => ucfirst( $mode ),
    767        
    768             'is_idle' => ( $mode === 'idle' ),
    769             'idle_until' => (int) ( $mode === 'idle' ? get_option( self::OPTION_IDLE_UNTIL, 0 ) : 0 ),
    770 ];
     1690            'status_semantic' => ( $is_break ? 'break' : $mode ),
     1691            'label'  => ( $is_break ? __( 'Break', 'statusdot' ) : ucfirst( $mode ) ),
     1692            'is_idle' => ( $mode === 'idle' && ! $is_break ),
     1693            'is_break' => $is_break,
     1694            'idle_until' => (int) ( $mode === 'idle' ? $effective_idle_until : 0 ),
     1695            'reason' => ( $mode_option === 'closed'
     1696                ? 'forced_closed'
     1697                : ( $is_break
     1698                    ? 'break'
     1699                    : ( ( $mode === 'idle' )
     1700                        ? 'idle'
     1701                        : ( ( $mode === 'busy' )
     1702                            ? 'busy'
     1703                            : ( ( $mode_option === 'open_247' ) ? 'open_247' : ( ( $mode === 'open' ) ? 'open' : 'closed' ) )
     1704                        )
     1705                    )
     1706                )
     1707            ),
     1708        ];
    7711709
    7721710        // Basic status text + countdown (Free). Pro can override via the filter below.
     
    7821720        $show_time_closed = (int) get_option( self::OPTION_SHOW_TIME_CLOSED, 1 );
    7831721
    784         $idle_until = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );
    785 
    786         $raw_countdown = self::get_basic_countdown_seconds( $mode, $now, $today, $open_hour, $open_min, $close_hour, $close_min, (int) $is_closed_today, $idle_until );
     1722        $raw_countdown = ( $mode_option !== 'normal' )
     1723            ? ( ( $mode === 'idle' ) ? self::get_basic_countdown_seconds( $mode, $now, $today, $open_hour, $open_min, $close_hour, $close_min, (int) $is_closed_today, $effective_idle_until ) : null )
     1724            : self::get_basic_countdown_seconds( $mode, $now, $today, $open_hour, $open_min, $close_hour, $close_min, (int) $is_closed_today, $effective_idle_until );
     1725        $break_time_display = $is_break && ! empty( $break_payload['end_display'] ) ? (string) $break_payload['end_display'] : '';
    7871726
    7881727        // Text: base status + optional label (even when time is hidden).
     
    7971736            (bool) $show_time_open,
    7981737            (bool) $show_time_idle,
    799             (bool) $show_time_closed
     1738            (bool) $show_time_closed,
     1739            $is_break,
     1740            $break_time_display
    8001741        );
    8011742
     
    8051746            if ( $mode === 'open' ) {
    8061747                $show_time = (bool) $show_time_open;
     1748            } elseif ( $mode === 'busy' ) {
     1749                $show_time = (bool) ( $show_time_open || $show_time_idle );
    8071750            } elseif ( $mode === 'closed' ) {
    8081751                $show_time = (bool) $show_time_closed;
     
    8141757        $data['countdown_seconds'] = ( $show_time && is_int( $raw_countdown ) ) ? $raw_countdown : null;
    8151758        $data['countdown'] = ( $show_time && is_int( $raw_countdown ) ) ? self::format_countdown_mmss( $raw_countdown ) : '';
     1759        $data['countdown_end_ms'] = ( $show_time && is_int( $raw_countdown ) && $raw_countdown > 0 ) ? (int) round( ( microtime( true ) + $raw_countdown ) * 1000 ) : 0;
     1760
     1761        if ( in_array( $mode, [ 'open', 'busy' ], true ) && is_array( $break_warning_payload ) ) {
     1762            $data = self::maybe_apply_break_warning_display( $data, $break_warning_payload );
     1763        }
    8161764// Pro integration: allow Pro (or others) to extend/override the payload
    8171765        $data = apply_filters('statusdot_status_data', $data, [
     
    8651813            array(
    8661814                'id'      => 'header',
    867                 'refresh' => '30',
     1815                'refresh' => '20',
    8681816            ),
    8691817            $atts,
     
    8751823
    8761824        // Free build: dot + basic status text + countdown (not customizable).
    877         $out  = '<span class="statusdot-wrap" data-statusdot-id="' . esc_attr( $id ) . '" data-statusdot-refresh="' . esc_attr( $refresh ) . '">';
     1825        $wrap_classes = 'statusdot-wrap';
     1826        if ( (int) get_option( self::OPTION_LIGHT_TEXT, 0 ) === 1 ) {
     1827            $wrap_classes .= ' statusdot-wrap--light-text';
     1828        }
     1829
     1830        $out  = '<span class="' . esc_attr( $wrap_classes ) . '" data-statusdot-id="' . esc_attr( $id ) . '" data-statusdot-refresh="' . esc_attr( $refresh ) . '">';
    8781831        $out .= '<span class="statusdot-status-icon statusdot-status-icon--unknown" data-statusdot-id="' . esc_attr( $id ) . '" data-statusdot-refresh="' . esc_attr( $refresh ) . '" aria-hidden="true"></span>';
    8791832        $out .= '<span class="screen-reader-text">' . esc_html__( 'Status indicator', 'statusdot' ) . '</span>';
     
    8951848
    8961849    /**
    897      * Default status text (Free): "Open now — Closes in", "Closed now — Opens in", "Busy now".
     1850     * Default status text (Free): "Open now — Closes in", "Closed now — Opens in", and
     1851     * when manual Busy is active during weekly hours, "Busy now — Closes in".
    8981852     */
    8991853    private static function get_default_status_text_with_label(
     
    9071861        bool $show_time_open,
    9081862        bool $show_time_idle,
    909         bool $show_time_closed
     1863        bool $show_time_closed,
     1864        bool $is_break = false,
     1865        string $break_time_display = ''
    9101866    ): string {
    9111867        $parts   = array();
     
    9191875            if ( is_int( $raw_countdown ) && $raw_countdown > 0 && $show_time_idle ) {
    9201876                $parts[] = esc_html__( 'Back in', 'statusdot' );
     1877            } elseif ( $is_break && $break_time_display !== '' && $show_text_busy ) {
     1878                /* translators: %s: break end time */
     1879                $parts[] = sprintf( esc_html__( 'We are back at %s', 'statusdot' ), $break_time_display );
    9211880            }
    9221881            return implode( $joiner, array_filter( $parts ) );
     
    9241883
    9251884        if ( $mode === 'busy' ) {
    926             return $show_text_busy ? esc_html__( 'Busy now', 'statusdot' ) : '';
     1885            if ( $show_text_busy ) {
     1886                $parts[] = esc_html__( 'Busy now', 'statusdot' );
     1887            }
     1888            if ( is_int( $raw_countdown ) && $raw_countdown > 0 && $show_lbl_closes && ( $show_time_open || $show_time_idle ) ) {
     1889                $parts[] = esc_html__( 'Closes in', 'statusdot' );
     1890            }
     1891            return implode( $joiner, array_filter( $parts ) );
    9271892        }
    9281893
     
    9621927    ): ?int {
    9631928
    964         // Idle: countdown to idle_until
    9651929        if ( $mode === 'idle' ) {
    9661930            $diff = $idle_until - time();
     
    9681932        }
    9691933
    970         // Busy (manual) never shows countdown in Free.
    971         if ( $mode === 'busy' ) {
    972             return null;
    973         }
    974 
    975         // If schedule closed today, no open countdown.
    976         if ( $is_closed_today ) {
    977             if ( $mode === 'open' ) {
     1934        $current_window = self::get_active_free_schedule_window( $now, 'normal' );
     1935        if ( $mode === 'busy' || $mode === 'open' ) {
     1936            if ( ! is_array( $current_window ) ) {
    9781937                return null;
    9791938            }
    980             // closed: find next open day
    981             return self::seconds_until_next_open_day( $now, $today );
    982         }
    983 
    984         try {
    985             $open_dt  = $now->setTime( $open_hour, $open_min, 0 );
    986             $close_dt = $now->setTime( $close_hour, $close_min, 0 );
    987         } catch ( Exception $e ) {
    988             return null;
    989         }
    990 
    991         if ( $mode === 'open' ) {
    992             $diff = $close_dt->getTimestamp() - $now->getTimestamp();
    993             return ( $diff > 0 ) ? $diff : null;
    994         }
    995 
    996         // closed: countdown to open
    997         $diff = $open_dt->getTimestamp() - $now->getTimestamp();
    998         if ( $diff > 0 ) {
    999             return $diff;
    1000         }
    1001 
    1002         // If we're already past today's opening time, go to next open day.
    1003         return self::seconds_until_next_open_day( $now, $today );
     1939            $diff = $current_window['close_dt']->getTimestamp() - $now->getTimestamp();
     1940            return ( $diff > 0 ) ? (int) $diff : null;
     1941        }
     1942
     1943        return self::get_seconds_until_next_free_open( $now );
    10041944    }
    10051945
    10061946    private static function seconds_until_next_open_day( DateTimeImmutable $now, ?string $today = null ): ?int {
    1007         $site_id = get_current_blog_id();
    1008         $tz = $now->getTimezone();
    1009 
    1010         for ( $i = 1; $i <= 7; $i++ ) {
    1011             $cand = $now->modify( '+' . $i . ' day' );
    1012             if ( ! $cand instanceof DateTimeImmutable ) {
    1013                 continue;
    1014             }
    1015             $day = strtolower( $cand->format( 'l' ) );
    1016 
    1017             $is_closed = (int) self::get_opt_with_fallback(
    1018                 self::OPTION_DAY_CLOSED_PREFIX . $day,
    1019                 'opening_hours_day_closed_' . $day . '_' . $site_id,
    1020                 0
    1021             );
    1022 
    1023             if ( $is_closed ) {
    1024                 continue;
    1025             }
    1026 
    1027                 $open_time = self::get_time_opt_with_fallback(
    1028                     self::OPTION_OPEN_PREFIX . $day,
    1029                     'opening_hours_open_' . $day . '_' . $site_id,
    1030                     '09:00'
    1031                 );
    1032                 list( $open_hour, $open_min ) = array_map( 'intval', explode( ':', $open_time ) );
    1033 
    1034             try {
    1035                     $open_dt = ( new DateTimeImmutable( $cand->format('Y-m-d') . ' 00:00:00', $tz ) )->setTime( $open_hour, $open_min, 0 );
    1036             } catch ( Exception $e ) {
    1037                 continue;
    1038             }
    1039 
    1040             $secs = (int) ( $open_dt->getTimestamp() - $now->getTimestamp() );
    1041             return $secs > 0 ? $secs : null;
    1042         }
    1043 
    1044         return null;
     1947        return self::get_seconds_until_next_free_open( $now );
    10451948    }
    10461949
    10471950    /**
    1048      * Format seconds as H:MM (>=1h) or M:SS (<1h). For Free display.
     1951     * Format seconds as a fixed-width HH:MM:SS string.
    10491952     */
    10501953    private static function format_countdown_mmss( int $seconds ): string {
     
    10531956        $m = (int) floor( ( $seconds % 3600 ) / 60 );
    10541957        $s = (int) ( $seconds % 60 );
    1055 
    1056         if ( $h > 0 ) {
    1057             return sprintf( '%d:%02d', $h, $m );
    1058         }
    1059         return sprintf( '%d:%02d', ( $m ), $s );
     1958        return sprintf( '%02d:%02d:%02d', $h, $m, $s );
    10601959    }
    10611960
  • statusdot/trunk/readme.txt

    r3476743 r3483265  
    22Contributors: designplug, freemius
    33Donate link: https://www.paypal.com/paypalme/DesignPlugNL
    4 Tags: opening-hours, business-hours, open-closed, countdown, status-indicator
     4Tags: opening-hours, business-hours, status-indicator, open-closed, countdown
    55Requires at least: 5.8
    66Tested up to: 6.9
    7 Stable tag: 2.1.0
     7Stable tag: 2.2.0
    88Requires PHP: 7.4
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
    11 Real-time opening hours with a clean status dot, optional text, and countdown timers.
     11Opening hours status dot with weekly schedules, overnight hours, breaks, busy/idle states, and live countdowns.
    1212
    1313== Description ==
     
    2525== Features ==
    2626* Weekly opening hours (HH:MM, including minutes)
     27* Overnight hours supported (set the close time earlier than the open time)
     28* Optional break windows during Weekly Schedule and Open 24/7
     29* Optional pre-break warnings before scheduled breaks
    2730* Status modes:
    2831  * Use Opening Hours (Weekly Schedule)
     
    3538  * Toggle countdown label + time per state (Closes in / Opens in / Back in)
    3639  * Separator selection (-, —, |, •)
    37 * Live countdown to the next opening/closing moment
     40  * Optional light text (white) output for dark backgrounds
     41* Live countdowns to the next opening, closing, break end, or idle return
    3842* AJAX-based live updates (configurable refresh interval)
    3943* Unlimited shortcodes per page
     
    4650
    4751Optional attributes:
    48 [statusdot id="header" refresh="30"]
     52[statusdot id="header" refresh="20"]
    4953
    5054* `id` – Optional unique identifier (useful for targeting with custom CSS). Default: header
    51 * `refresh` – Refresh interval in seconds (default: 30)
     55* `refresh` – Refresh interval in seconds (default: 20)
    5256
    5357== Installation ==
     
    7579
    7680== Screenshots ==
    77 1. Frontend open / busy / closed status with countdown
    78 2. Settings page (schedule + display options)
     811. Frontend example showing Open / Busy / Closed with live countdown
     822. Frontend Idle override example with “Back in” countdown
     833. Frontend break status example with live return countdown
     844. Main settings page with weekly schedule, breaks, and display options
     855. Dark-background frontend output with light text enabled
    7986
    8087== Changelog ==
     88
     89= 2.2.0 =
     90* New: Added overnight opening hours, so a closing time earlier than the opening time now continues into the next day.
     91* New: Added optional scheduled break windows for Weekly Schedule and Open 24/7, including support for multiple break slots per day.
     92* New: Added optional pre-break warnings with configurable minutes-before, content display, and rotation behavior.
     93* New: Added an optional light text (white) setting for shortcode text and countdown output on dark backgrounds.
     94* Improved: Live status updates now handle opening, closing, break, and idle countdown boundaries more smoothly without a full page refresh.
     95* Improved: The admin live state handling now refreshes Busy / Break / Idle states more reliably, and Idle override can no longer be started while the schedule is closed.
     96* Meta: Refreshed the WordPress.org screenshots/readme visuals for the 2.2.0 release.
     97
    8198= 2.1.0 =
    82 * New: Display options (status text + countdown label/time toggles)
    83 * New: Separator selection (-, —, |, •)
    84 * New: Idle override (Back in...) with start/stop
    85 * Improved: Weekly schedule supports HH:MM (minutes)
    86 * Improved: Instant refresh after saving settings
    87 * UI polish and WordPress.org compliance fixes
    88 
    89 = 2.0.1 =
    90 * Add basic status text and countdown to the free version
    91 * Improve shortcode copy/paste formatting
    92 * Minor readme improvements
     99* New: Added display options for status text and countdown labels/times.
     100* New: Added separator selection (-, —, |, •) for the shortcode output.
     101* New: Added Idle override ("Back in...") with start/stop and countdown support.
     102* Improved: Weekly schedules now support HH:MM values, including minutes.
     103* Improved: Settings changes now refresh more immediately after saving.
     104* Improved: General admin UI polish and WordPress.org compliance updates.
    93105
    94106= 2.0.0 =
    95 * Licensing integration (optional upgrade)
    96 * Code quality improvements
    97 * WordPress.org compatibility fixes
    98 * Performance improvements
    99 
    100 = 1.0.0 =
    101 * Initial release
     107* Added Freemius integration for the optional upgrade path.
     108* Improved: Code quality, performance, and WordPress.org compatibility.
    102109
    103110== Upgrade Notice ==
    104 = 2.1.0 =
    105 Adds Display Options, Separator selection, and an Idle override — plus HH:MM schedule support and instant refresh after saving settings.
    106 
    107 = 2.0.1 =
    108 Adds basic status text and countdown display to the free version.
     111= 2.2.0 =
     112Adds overnight hours, scheduled break windows, optional pre-break warnings, light text output, and smoother live state handling for countdowns, breaks, and idle mode.
  • statusdot/trunk/statusdot.php

    r3476743 r3483265  
    33/**
    44 * Plugin Name: StatusDot
    5  * Description: Minimal opening hours status dot (open/busy/closed).
    6  * Version:     2.1.0
     5 * Description: Opening hours status dot with weekly schedules, overnight hours, breaks, busy/idle states, and live countdowns.
     6 * Version:     2.2.0
    77 * Author:      Design Plug
    88 * Author URI:  https://profiles.wordpress.org/designplug/
     
    6060// Plugin constants
    6161// ----------------------------
    62 define( 'STATUSDOT_VERSION', '2.1.0' );
     62define( 'STATUSDOT_VERSION', '2.2.0' );
    6363define( 'STATUSDOT_PLUGIN_FILE', __FILE__ );
    6464define( 'STATUSDOT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
     
    7878        public function init_modules() {
    7979            StatusDot_Opening_Hours::init();
    80             // Load Pro module only in the premium (Pro) build. This block is stripped from the Free build by Freemius.
    81             if ( function_exists( 'statusdot_fs' ) && statusdot_fs()->is__premium_only() ) {
     80            // Load Pro module only when the premium build is installed *and* premium code is allowed.
     81            // This lets a premium ZIP gracefully fall back to the Free feature set until a valid
     82            // license / trial is active, instead of exposing paid settings just because the Pro ZIP exists.
     83            if ( function_exists( 'statusdot_fs' ) && statusdot_fs()->is__premium_only() && statusdot_fs()->can_use_premium_code__premium_only() ) {
    8284                $pro_file = STATUSDOT_PLUGIN_DIR . 'includes/class-statusdot-pro__premium_only.php';
    8385                if ( file_exists( $pro_file ) ) {
     
    146148                true
    147149            );
     150            wp_localize_script( 'statusdot-admin', 'StatusDotAdmin', [
     151                'ajax_url' => admin_url( 'admin-ajax.php' ),
     152                'nonce'    => wp_create_nonce( 'statusdot_frontend' ),
     153            ] );
    148154            wp_enqueue_script( 'statusdot-admin' );
    149155        }
Note: See TracChangeset for help on using the changeset viewer.