Index: /navarro-booking-for-woocommerce/assets/icon.svg
===================================================================
--- /navarro-booking-for-woocommerce/assets/icon.svg	(revision 3750436)
+++ /navarro-booking-for-woocommerce/assets/icon.svg	(revision 3750436)
@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Navarro Booking">
+  <rect width="64" height="64" rx="14" fill="#16202B"/>
+  <rect x="10" y="11" width="44" height="5" rx="2.5" fill="#FFFFFF" opacity=".4"/>
+  <rect x="10" y="24" width="12" height="12" rx="3.5" fill="#FFFFFF" opacity=".85"/>
+  <rect x="26" y="24" width="12" height="12" rx="3.5" fill="#E2653C"/>
+  <rect x="42" y="24" width="12" height="12" rx="3.5" fill="#FFFFFF" opacity=".85"/>
+  <rect x="10" y="41" width="12" height="12" rx="3.5" fill="#FFFFFF" opacity=".85"/>
+  <rect x="26" y="41" width="12" height="12" rx="3.5" fill="#FFFFFF" opacity=".85"/>
+  <rect x="42" y="41" width="12" height="12" rx="3.5" fill="#FFFFFF" opacity=".85"/>
+</svg>
Index: /navarro-booking-for-woocommerce/tags/2.5.0/LICENSE
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/LICENSE	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/LICENSE	(revision 3750436)
@@ -0,0 +1,359 @@
+Navarro Booking for WooCommerce
+Copyright (C) 2025-2026 Dominick Navarro
+
+This program is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation; either version 2 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
+Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Flatpickr is bundled under the MIT License and remains under its own terms.
+See readme.txt, Third-Party Libraries.
+
+----------------------------------------------------------------------
+
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Moe Ghoul>, 1 April 1989
+  Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/admin-diagnostics.css
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/admin-diagnostics.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/admin-diagnostics.css	(revision 3750436)
@@ -0,0 +1,14 @@
+/* Timezone diagnostics tab. */
+.calentra-warn {
+    color: #b32d2e;
+    margin: .3em 0;
+}
+
+.calentra-diag th {
+    width: auto;
+}
+
+/* Each cell carries the start time and the end time on separate lines. */
+.calentra-viewer-time {
+    white-space: pre-line;
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/admin-upgrade.css
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/admin-upgrade.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/admin-upgrade.css	(revision 3750436)
@@ -0,0 +1,64 @@
+/* Locked Pro features (Calentra_Upgrade). Muted, never styled as an input. */
+.calentra-locks {
+    margin: 8px 0;
+}
+.calentra-lock {
+    margin: 4px 0;
+    color: #646970;
+}
+.calentra-lock > summary {
+    cursor: pointer;
+    list-style: none;
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+}
+.calentra-lock > summary::-webkit-details-marker {
+    display: none;
+}
+.calentra-lock > summary .dashicons {
+    font-size: 16px;
+    width: 16px;
+    height: 16px;
+}
+.calentra-lock > summary:hover,
+.calentra-lock[open] > summary {
+    color: #1d2327;
+}
+.calentra-lock__panel {
+    margin: 4px 0 0 18px;
+    max-width: 40em;
+}
+.calentra-lock__panel p {
+    margin: 0 0 4px;
+}
+
+/* WooCommerce → Bookings: inline beside the page title, panel floats over the page. */
+.calentra-locks--bookings_actions {
+    display: inline-flex;
+    flex-wrap: wrap;
+    gap: 12px;
+    margin: 0 0 0 8px;
+    vertical-align: middle;
+}
+.calentra-locks--bookings_actions .calentra-lock {
+    position: relative;
+    margin: 0;
+}
+.calentra-locks--bookings_actions .calentra-lock__panel {
+    position: absolute;
+    z-index: 10;
+    top: 100%;
+    left: 0;
+    width: 22em;
+    margin: 4px 0 0;
+    padding: 8px 12px;
+    background: #fff;
+    border: 1px solid #c3c4c7;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+    color: #1d2327;
+}
+
+.calentra-upgrade-foot {
+    margin-top: 2em;
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/flatpickr.min.css
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/flatpickr.min.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/flatpickr.min.css	(revision 3750436)
@@ -0,0 +1,13 @@
+.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/*
+      /*rtl:begin:ignore*/left:0/*
+      /*rtl:end:ignore*/}/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/*
+      /*rtl:begin:ignore*/right:0/*
+      /*rtl:end:ignore*/}/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/style.css
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/style.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/css/style.css	(revision 3750436)
@@ -0,0 +1,53 @@
+/* Navarro Booking Plugin Styles */
+.calentra-booking {
+    padding: 10px;
+    border-left: 4px solid #F5B942;
+}
+
+.calentra-booking-ui {
+    padding: 10px;
+    border-left: 4px solid #F5B942;
+    background: #fefefe;
+    margin-bottom: 1rem;
+}
+
+.calentra-booking-ui label {
+    font-weight: 600;
+}
+
+/* Make date and time fields uniform */
+.calentra-booking-ui input[type="text"],
+.calentra-booking-ui select {
+    height: 42px;
+    width: 100%;
+    padding: 6px 10px;
+    font-size: 14px;
+    box-sizing: border-box;
+    margin-top: 4px;
+}
+
+.calentra-booking-fields {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    margin-bottom: 20px;
+    border-left: 4px solid #F5B942;
+    padding-left: 10px;
+}
+
+.calentra-booking-fields label {
+    font-weight: 600;
+    font-size: 14px;
+    margin-bottom: 4px;
+}
+
+.calentra-booking-fields input[type="text"],
+.calentra-booking-fields select {
+    padding: 10px 12px;
+    font-size: 15px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    width: 100%;
+    box-sizing: border-box;
+}
+
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/admin-diagnostics.js
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/admin-diagnostics.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/admin-diagnostics.js	(revision 3750436)
@@ -0,0 +1,40 @@
+/**
+ * Timezone diagnostics: fills in what *this browser* thinks the time is, so the
+ * viewer's clock can be compared against the site's, the server's and the database's.
+ *
+ * Everything here is client-side by necessity — the viewer's timezone is not something
+ * the server can know. Settings come from wp_localize_script as calentraDiagnostics.
+ */
+(function () {
+    'use strict';
+
+    var settings = window.calentraDiagnostics || {};
+    var viewerTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+    var format = new Intl.DateTimeFormat(undefined, {
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+        hour: '2-digit',
+        minute: '2-digit',
+        timeZoneName: 'short'
+    });
+
+    function setText(id, text) {
+        var el = document.getElementById(id);
+        if (el) {
+            el.textContent = text;
+        }
+    }
+
+    setText('calentra-viewer-tz', viewerTz);
+    setText('calentra-viewer-now', format.format(new Date()));
+
+    if (settings.siteTimezone && viewerTz !== settings.siteTimezone) {
+        setText('calentra-viewer-note', settings.differentTimezoneNote || '');
+    }
+
+    document.querySelectorAll('.calentra-viewer-time').forEach(function (cell) {
+        cell.textContent = format.format(new Date(cell.dataset.start)) + '\n' +
+            format.format(new Date(cell.dataset.end));
+    });
+})();
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/admin-ui.js
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/admin-ui.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/admin-ui.js	(revision 3750436)
@@ -0,0 +1,25 @@
+/* global flatpickr */
+jQuery(function ($) {
+    // Inputs stay free-text (allowInput) so values can be pasted; the server parses and
+    // reports anything it can't read.
+    $('.calentra-blocked-dates').each(function () {
+        flatpickr(this, { mode: 'multiple', dateFormat: 'Y-m-d', conjunction: ', ', allowInput: true });
+    });
+
+    $('.calentra-blocked-slots').each(function () {
+        flatpickr(this, { mode: 'multiple', enableTime: true, dateFormat: 'Y-m-d H:i', conjunction: ', ', time_24hr: true, allowInput: true });
+    });
+
+    function initOrderPickers(root) {
+        $(root).find('.calentra-flatpickr-date').each(function () {
+            if (!this._flatpickr) flatpickr(this, { dateFormat: 'Y-m-d', allowInput: true });
+        });
+        $(root).find('.calentra-flatpickr-time').each(function () {
+            if (!this._flatpickr) flatpickr(this, { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, allowInput: true });
+        });
+    }
+    initOrderPickers(document);
+    // The order items table is re-rendered by AJAX after saves and edits.
+    $(document.body).on('wc_order_items_reloaded items_saved', () => initOrderPickers(document));
+    $('#woocommerce-order-items').on('click', '.edit-order-item', () => setTimeout(() => initOrderPickers(document), 0));
+});
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/booking.js
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/booking.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/booking.js	(revision 3750436)
@@ -0,0 +1,228 @@
+/* global flatpickr, calentra_booking */
+jQuery(function ($) {
+    const $dateInput = $('#calentra_booking_date');
+
+    // Nothing to wire up if this page has no booking field.
+    if (!$dateInput.length || typeof calentra_booking === 'undefined') {
+        return;
+    }
+
+    const cfg = calentra_booking;
+    const t = cfg.i18n;
+    const siteTz = cfg.site_timezone;
+    const viewerTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+    const $timeSelect = $('#calentra_booking_time');
+    const $endInput = $('#calentra_booking_end_date');
+    const $error = $('#calentra_error');
+    // An extension may add a resource choice (staff, room) to the form. It is static
+    // markup, so cached pages keep working; availability is asked for per resource.
+    const $resource = $dateInput.closest('form').find('[name="calentra_resource_id"]');
+    const resourceId = () => ($resource.length ? String($resource.val() || '0') : '');
+
+    // Calendar dates are always the site's calendar dates, as "YYYY-MM-DD".
+    const ymd = (date) => date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0');
+    const addDays = (str, n) => {
+        const d = new Date(str + 'T12:00:00');
+        d.setDate(d.getDate() + n);
+        return ymd(d);
+    };
+    const siteToday = () => new Intl.DateTimeFormat('en-CA', { timeZone: siteTz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
+
+    function showError(message) {
+        $error.text(message || '').prop('hidden', !message);
+    }
+
+    // ── Availability, fetched by range and kept per date ───────────────────────
+    const known = {}; // date -> slots array (timeslot) or seats number (multi-day)
+    const loaded = {}; // range key -> promise
+
+    function load(from) {
+        const to = addDays(from, 41); // a six-week calendar page
+        const resource = resourceId();
+        const key = resource + '|' + from + '|' + to;
+        if (!loaded[key]) {
+            const params = { product_id: cfg.product_id, from: from, to: to };
+            if (resource !== '') params.resource_id = resource;
+            const url = cfg.rest_url + (cfg.rest_url.indexOf('?') === -1 ? '?' : '&') + $.param(params);
+            // No nonce: the endpoint is public, so a cached page can't go stale on it.
+            loaded[key] = fetch(url, { credentials: 'same-origin' })
+                .then((r) => {
+                    if (!r.ok) throw new Error(r.status);
+                    return r.json();
+                })
+                .then((data) => {
+                    // Drop an answer that arrives after the customer changed resource.
+                    if (resource === resourceId()) {
+                        Object.assign(known, data.days || data.nights || {});
+                    }
+                    return data;
+                })
+                .catch((e) => {
+                    delete loaded[key];
+                    showError(t.load_error);
+                    throw e;
+                });
+        }
+        return loaded[key];
+    }
+
+    function visibleFrom(instance) {
+        // The calendar page shows up to a week of the previous month.
+        const from = addDays(ymd(new Date(instance.currentYear, instance.currentMonth, 1)), -7);
+        const today = siteToday();
+        return from < today ? today : from;
+    }
+
+    const maxDate = addDays(siteToday(), cfg.max_days);
+
+    // ── Time-zone aware slot labels ───────────────────────────────────────────
+    function zoneName(date, timeZone) {
+        const part = new Intl.DateTimeFormat(undefined, { timeZone: timeZone, timeZoneName: 'short' })
+            .formatToParts(date).find((p) => p.type === 'timeZoneName');
+        return part ? part.value : timeZone;
+    }
+    function clock(date, timeZone) {
+        return new Intl.DateTimeFormat(undefined, { timeZone: timeZone, hour: '2-digit', minute: '2-digit' }).format(date);
+    }
+    function dayOf(date, timeZone) {
+        return new Intl.DateTimeFormat('en-CA', { timeZone: timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
+    }
+    function slotLabel(slot) {
+        const start = new Date(slot.start);
+        let label = clock(start, siteTz) + ' ' + zoneName(start, siteTz);
+        if (viewerTz && viewerTz !== siteTz && zoneName(start, viewerTz) !== zoneName(start, siteTz)) {
+            label += ' · ' + clock(start, viewerTz) + ' ' + zoneName(start, viewerTz) + ' (' + t.your_time + ')';
+            if (dayOf(start, viewerTz) !== dayOf(start, siteTz)) {
+                label += ' ' + new Intl.DateTimeFormat(undefined, { timeZone: viewerTz, weekday: 'short', day: 'numeric', month: 'short' }).format(start);
+            }
+        }
+        return label;
+    }
+
+    // ── Timeslot products ─────────────────────────────────────────────────────
+    function fillSlots(dateStr) {
+        const slots = known[dateStr] || [];
+        $timeSelect.empty();
+        if (!slots.length) {
+            $timeSelect.append($('<option>', { value: '', text: t.no_slots, disabled: true, selected: true }));
+            return;
+        }
+        $timeSelect.append($('<option>', { value: '', text: t.select_slot }));
+        slots.forEach((slot) => {
+            $timeSelect.append($('<option>', { value: slot.local, text: slotLabel(slot) }));
+        });
+        const sample = new Date(slots[0].start);
+        $('#calentra_tz_note').text(t.times_in.replace('%s', siteTz + ' (' + zoneName(sample, siteTz) + ')'));
+    }
+
+    const pickers = [];
+    const refresh = (instance) => load(visibleFrom(instance)).then(() => instance.redraw()).catch(() => {});
+
+    function initTimeslot() {
+        const picker = flatpickr($dateInput[0], {
+            minDate: siteToday(),
+            maxDate: maxDate,
+            dateFormat: 'Y-m-d',
+            disable: [(date) => {
+                const key = ymd(date);
+                return key in known && known[key].length === 0;
+            }],
+            onReady: (sel, str, instance) => refresh(instance),
+            onMonthChange: (sel, str, instance) => refresh(instance),
+            onYearChange: (sel, str, instance) => refresh(instance),
+            onChange: (selectedDates, dateStr) => {
+                showError('');
+                if (!dateStr) return;
+                $timeSelect.html($('<option>', { value: '', text: t.loading }));
+                load(dateStr).then(() => fillSlots(dateStr)).catch(() => {});
+            },
+        });
+        pickers.push(picker);
+    }
+
+    // ── Multi-day products: nights from start date up to (not including) end date ──
+    function initMultiDay() {
+        const free = (key) => !(key in known) || known[key] > 0;
+
+        // The end date is the departure date: it may be a booked night, but every
+        // night between start and end must be free.
+        const endPicker = flatpickr($endInput[0], {
+            dateFormat: 'Y-m-d',
+            minDate: addDays(siteToday(), 1),
+            maxDate: addDays(maxDate, 1),
+            disable: [(date) => {
+                const start = $dateInput.val();
+                const key = ymd(date);
+                if (!start || key <= start) return true;
+                for (let d = start; d < key; d = addDays(d, 1)) {
+                    if (!free(d)) return true;
+                }
+                return false;
+            }],
+            onReady: (sel, str, instance) => refresh(instance),
+            onMonthChange: (sel, str, instance) => refresh(instance),
+            onChange: () => {
+                showError('');
+                updateNights();
+            },
+        });
+
+        const startPicker = flatpickr($dateInput[0], {
+            minDate: siteToday(),
+            maxDate: maxDate,
+            dateFormat: 'Y-m-d',
+            disable: [(date) => !free(ymd(date))],
+            onReady: (sel, str, instance) => refresh(instance),
+            onMonthChange: (sel, str, instance) => refresh(instance),
+            onChange: (selectedDates, dateStr) => {
+                showError('');
+                if (!dateStr) return;
+                const end = $endInput.val();
+                endPicker.set('minDate', addDays(dateStr, 1));
+                if (end && end <= dateStr) endPicker.clear();
+                load(dateStr).then(() => endPicker.redraw()).catch(() => {});
+                updateNights();
+            },
+        });
+        pickers.push(startPicker, endPicker);
+    }
+
+    function updateNights() {
+        const start = $dateInput.val();
+        const end = $endInput.val();
+        if (!start || !end || end <= start) {
+            $('#calentra_nights').text('');
+            return;
+        }
+        const nights = Math.round((new Date(end + 'T12:00:00') - new Date(start + 'T12:00:00')) / 86400000);
+        const form = nights === 1 ? t.nights.singular : t.nights.plural;
+        $('#calentra_nights').text(form.replace('%d', nights));
+    }
+
+    if (cfg.type === 'multi-day') {
+        initMultiDay();
+    } else {
+        initTimeslot();
+    }
+
+    // A different resource has different availability: forget what was loaded, clear
+    // choices that may not exist for it, and ask again.
+    $resource.on('change', () => {
+        Object.keys(known).forEach((k) => delete known[k]);
+        Object.keys(loaded).forEach((k) => delete loaded[k]);
+        showError('');
+        pickers.forEach((p) => p.clear());
+        $timeSelect.html($('<option>', { value: '', text: t.select_slot }));
+        updateNights();
+        pickers.forEach(refresh);
+    });
+
+    // Catch an incomplete choice before the round trip; the server validates regardless.
+    $('form.cart').on('submit', function (e) {
+        const date = $dateInput.val();
+        if (cfg.type === 'multi-day' ? !date || !$endInput.val() : !date || !$timeSelect.val()) {
+            showError(cfg.type === 'multi-day' ? t.pick_dates : t.pick_date_time);
+            e.preventDefault();
+        }
+    });
+});
Index: /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/flatpickr.min.js
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/flatpickr.min.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/assets/js/flatpickr.min.js	(revision 3750436)
@@ -0,0 +1,2 @@
+/* flatpickr v4.6.13,, @license MIT */
+!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).flatpickr=n()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var n,t=1,a=arguments.length;t<a;t++)for(var i in n=arguments[t])Object.prototype.hasOwnProperty.call(n,i)&&(e[i]=n[i]);return e}).apply(this,arguments)};function n(){for(var e=0,n=0,t=arguments.length;n<t;n++)e+=arguments[n].length;var a=Array(e),i=0;for(n=0;n<t;n++)for(var o=arguments[n],r=0,l=o.length;r<l;r++,i++)a[i]=o[r];return a}var t=["onChange","onClose","onDayCreate","onDestroy","onKeyDown","onMonthChange","onOpen","onParseConfig","onReady","onValueUpdate","onYearChange","onPreCalendarPosition"],a={_disable:[],allowInput:!1,allowInvalidPreload:!1,altFormat:"F j, Y",altInput:!1,altInputClass:"form-control input",animate:"object"==typeof window&&-1===window.navigator.userAgent.indexOf("MSIE"),ariaDateFormat:"F j, Y",autoFillDefaultTime:!0,clickOpens:!0,closeOnSelect:!0,conjunction:", ",dateFormat:"Y-m-d",defaultHour:12,defaultMinute:0,defaultSeconds:0,disable:[],disableMobile:!1,enableSeconds:!1,enableTime:!1,errorHandler:function(e){return"undefined"!=typeof console&&console.warn(e)},getWeek:function(e){var n=new Date(e.getTime());n.setHours(0,0,0,0),n.setDate(n.getDate()+3-(n.getDay()+6)%7);var t=new Date(n.getFullYear(),0,4);return 1+Math.round(((n.getTime()-t.getTime())/864e5-3+(t.getDay()+6)%7)/7)},hourIncrement:1,ignoredFocusElements:[],inline:!1,locale:"default",minuteIncrement:5,mode:"single",monthSelectorType:"dropdown",nextArrow:"<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 17 17'><g></g><path d='M13.207 8.472l-7.854 7.854-0.707-0.707 7.146-7.146-7.146-7.148 0.707-0.707 7.854 7.854z' /></svg>",noCalendar:!1,now:new Date,onChange:[],onClose:[],onDayCreate:[],onDestroy:[],onKeyDown:[],onMonthChange:[],onOpen:[],onParseConfig:[],onReady:[],onValueUpdate:[],onYearChange:[],onPreCalendarPosition:[],plugins:[],position:"auto",positionElement:void 0,prevArrow:"<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 17 17'><g></g><path d='M5.207 8.471l7.146 7.147-0.707 0.707-7.853-7.854 7.854-7.853 0.707 0.707-7.147 7.146z' /></svg>",shorthandCurrentMonth:!1,showMonths:1,static:!1,time_24hr:!1,weekNumbers:!1,wrap:!1},i={weekdays:{shorthand:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],longhand:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},months:{shorthand:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],longhand:["January","February","March","April","May","June","July","August","September","October","November","December"]},daysInMonth:[31,28,31,30,31,30,31,31,30,31,30,31],firstDayOfWeek:0,ordinal:function(e){var n=e%100;if(n>3&&n<21)return"th";switch(n%10){case 1:return"st";case 2:return"nd";case 3:return"rd";default:return"th"}},rangeSeparator:" to ",weekAbbreviation:"Wk",scrollTitle:"Scroll to increment",toggleTitle:"Click to toggle",amPM:["AM","PM"],yearAriaLabel:"Year",monthAriaLabel:"Month",hourAriaLabel:"Hour",minuteAriaLabel:"Minute",time_24hr:!1},o=function(e,n){return void 0===n&&(n=2),("000"+e).slice(-1*n)},r=function(e){return!0===e?1:0};function l(e,n){var t;return function(){var a=this,i=arguments;clearTimeout(t),t=setTimeout((function(){return e.apply(a,i)}),n)}}var c=function(e){return e instanceof Array?e:[e]};function s(e,n,t){if(!0===t)return e.classList.add(n);e.classList.remove(n)}function d(e,n,t){var a=window.document.createElement(e);return n=n||"",t=t||"",a.className=n,void 0!==t&&(a.textContent=t),a}function u(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function f(e,n){return n(e)?e:e.parentNode?f(e.parentNode,n):void 0}function m(e,n){var t=d("div","numInputWrapper"),a=d("input","numInput "+e),i=d("span","arrowUp"),o=d("span","arrowDown");if(-1===navigator.userAgent.indexOf("MSIE 9.0")?a.type="number":(a.type="text",a.pattern="\\d*"),void 0!==n)for(var r in n)a.setAttribute(r,n[r]);return t.appendChild(a),t.appendChild(i),t.appendChild(o),t}function g(e){try{return"function"==typeof e.composedPath?e.composedPath()[0]:e.target}catch(n){return e.target}}var p=function(){},h=function(e,n,t){return t.months[n?"shorthand":"longhand"][e]},v={D:p,F:function(e,n,t){e.setMonth(t.months.longhand.indexOf(n))},G:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},H:function(e,n){e.setHours(parseFloat(n))},J:function(e,n){e.setDate(parseFloat(n))},K:function(e,n,t){e.setHours(e.getHours()%12+12*r(new RegExp(t.amPM[1],"i").test(n)))},M:function(e,n,t){e.setMonth(t.months.shorthand.indexOf(n))},S:function(e,n){e.setSeconds(parseFloat(n))},U:function(e,n){return new Date(1e3*parseFloat(n))},W:function(e,n,t){var a=parseInt(n),i=new Date(e.getFullYear(),0,2+7*(a-1),0,0,0,0);return i.setDate(i.getDate()-i.getDay()+t.firstDayOfWeek),i},Y:function(e,n){e.setFullYear(parseFloat(n))},Z:function(e,n){return new Date(n)},d:function(e,n){e.setDate(parseFloat(n))},h:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},i:function(e,n){e.setMinutes(parseFloat(n))},j:function(e,n){e.setDate(parseFloat(n))},l:p,m:function(e,n){e.setMonth(parseFloat(n)-1)},n:function(e,n){e.setMonth(parseFloat(n)-1)},s:function(e,n){e.setSeconds(parseFloat(n))},u:function(e,n){return new Date(parseFloat(n))},w:p,y:function(e,n){e.setFullYear(2e3+parseFloat(n))}},D={D:"",F:"",G:"(\\d\\d|\\d)",H:"(\\d\\d|\\d)",J:"(\\d\\d|\\d)\\w+",K:"",M:"",S:"(\\d\\d|\\d)",U:"(.+)",W:"(\\d\\d|\\d)",Y:"(\\d{4})",Z:"(.+)",d:"(\\d\\d|\\d)",h:"(\\d\\d|\\d)",i:"(\\d\\d|\\d)",j:"(\\d\\d|\\d)",l:"",m:"(\\d\\d|\\d)",n:"(\\d\\d|\\d)",s:"(\\d\\d|\\d)",u:"(.+)",w:"(\\d\\d|\\d)",y:"(\\d{2})"},w={Z:function(e){return e.toISOString()},D:function(e,n,t){return n.weekdays.shorthand[w.w(e,n,t)]},F:function(e,n,t){return h(w.n(e,n,t)-1,!1,n)},G:function(e,n,t){return o(w.h(e,n,t))},H:function(e){return o(e.getHours())},J:function(e,n){return void 0!==n.ordinal?e.getDate()+n.ordinal(e.getDate()):e.getDate()},K:function(e,n){return n.amPM[r(e.getHours()>11)]},M:function(e,n){return h(e.getMonth(),!0,n)},S:function(e){return o(e.getSeconds())},U:function(e){return e.getTime()/1e3},W:function(e,n,t){return t.getWeek(e)},Y:function(e){return o(e.getFullYear(),4)},d:function(e){return o(e.getDate())},h:function(e){return e.getHours()%12?e.getHours()%12:12},i:function(e){return o(e.getMinutes())},j:function(e){return e.getDate()},l:function(e,n){return n.weekdays.longhand[e.getDay()]},m:function(e){return o(e.getMonth()+1)},n:function(e){return e.getMonth()+1},s:function(e){return e.getSeconds()},u:function(e){return e.getTime()},w:function(e){return e.getDay()},y:function(e){return String(e.getFullYear()).substring(2)}},b=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o,l=e.isMobile,c=void 0!==l&&l;return function(e,n,a){var i=a||r;return void 0===t.formatDate||c?n.split("").map((function(n,a,o){return w[n]&&"\\"!==o[a-1]?w[n](e,i,t):"\\"!==n?n:""})).join(""):t.formatDate(e,n,i)}},C=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o;return function(e,n,i,o){if(0===e||e){var l,c=o||r,s=e;if(e instanceof Date)l=new Date(e.getTime());else if("string"!=typeof e&&void 0!==e.toFixed)l=new Date(e);else if("string"==typeof e){var d=n||(t||a).dateFormat,u=String(e).trim();if("today"===u)l=new Date,i=!0;else if(t&&t.parseDate)l=t.parseDate(e,d);else if(/Z$/.test(u)||/GMT$/.test(u))l=new Date(e);else{for(var f=void 0,m=[],g=0,p=0,h="";g<d.length;g++){var w=d[g],b="\\"===w,C="\\"===d[g-1]||b;if(D[w]&&!C){h+=D[w];var M=new RegExp(h).exec(e);M&&(f=!0)&&m["Y"!==w?"push":"unshift"]({fn:v[w],val:M[++p]})}else b||(h+=".")}l=t&&t.noCalendar?new Date((new Date).setHours(0,0,0,0)):new Date((new Date).getFullYear(),0,1,0,0,0,0),m.forEach((function(e){var n=e.fn,t=e.val;return l=n(l,t,c)||l})),l=f?l:void 0}}if(l instanceof Date&&!isNaN(l.getTime()))return!0===i&&l.setHours(0,0,0,0),l;t.errorHandler(new Error("Invalid date provided: "+s))}}};function M(e,n,t){return void 0===t&&(t=!0),!1!==t?new Date(e.getTime()).setHours(0,0,0,0)-new Date(n.getTime()).setHours(0,0,0,0):e.getTime()-n.getTime()}var y=function(e,n,t){return 3600*e+60*n+t},x=864e5;function E(e){var n=e.defaultHour,t=e.defaultMinute,a=e.defaultSeconds;if(void 0!==e.minDate){var i=e.minDate.getHours(),o=e.minDate.getMinutes(),r=e.minDate.getSeconds();n<i&&(n=i),n===i&&t<o&&(t=o),n===i&&t===o&&a<r&&(a=e.minDate.getSeconds())}if(void 0!==e.maxDate){var l=e.maxDate.getHours(),c=e.maxDate.getMinutes();(n=Math.min(n,l))===l&&(t=Math.min(c,t)),n===l&&t===c&&(a=e.maxDate.getSeconds())}return{hours:n,minutes:t,seconds:a}}"function"!=typeof Object.assign&&(Object.assign=function(e){for(var n=[],t=1;t<arguments.length;t++)n[t-1]=arguments[t];if(!e)throw TypeError("Cannot convert undefined or null to object");for(var a=function(n){n&&Object.keys(n).forEach((function(t){return e[t]=n[t]}))},i=0,o=n;i<o.length;i++){var r=o[i];a(r)}return e});function k(p,v){var w={config:e(e({},a),I.defaultConfig),l10n:i};function k(){var e;return(null===(e=w.calendarContainer)||void 0===e?void 0:e.getRootNode()).activeElement||document.activeElement}function T(e){return e.bind(w)}function S(){var e=w.config;!1===e.weekNumbers&&1===e.showMonths||!0!==e.noCalendar&&window.requestAnimationFrame((function(){if(void 0!==w.calendarContainer&&(w.calendarContainer.style.visibility="hidden",w.calendarContainer.style.display="block"),void 0!==w.daysContainer){var n=(w.days.offsetWidth+1)*e.showMonths;w.daysContainer.style.width=n+"px",w.calendarContainer.style.width=n+(void 0!==w.weekWrapper?w.weekWrapper.offsetWidth:0)+"px",w.calendarContainer.style.removeProperty("visibility"),w.calendarContainer.style.removeProperty("display")}}))}function _(e){if(0===w.selectedDates.length){var n=void 0===w.config.minDate||M(new Date,w.config.minDate)>=0?new Date:new Date(w.config.minDate.getTime()),t=E(w.config);n.setHours(t.hours,t.minutes,t.seconds,n.getMilliseconds()),w.selectedDates=[n],w.latestSelectedDateObj=n}void 0!==e&&"blur"!==e.type&&function(e){e.preventDefault();var n="keydown"===e.type,t=g(e),a=t;void 0!==w.amPM&&t===w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]);var i=parseFloat(a.getAttribute("min")),l=parseFloat(a.getAttribute("max")),c=parseFloat(a.getAttribute("step")),s=parseInt(a.value,10),d=e.delta||(n?38===e.which?1:-1:0),u=s+c*d;if(void 0!==a.value&&2===a.value.length){var f=a===w.hourElement,m=a===w.minuteElement;u<i?(u=l+u+r(!f)+(r(f)&&r(!w.amPM)),m&&L(void 0,-1,w.hourElement)):u>l&&(u=a===w.hourElement?u-l-r(!w.amPM):i,m&&L(void 0,1,w.hourElement)),w.amPM&&f&&(1===c?u+s===23:Math.abs(u-s)>c)&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]),a.value=o(u)}}(e);var a=w._input.value;O(),ye(),w._input.value!==a&&w._debouncedChange()}function O(){if(void 0!==w.hourElement&&void 0!==w.minuteElement){var e,n,t=(parseInt(w.hourElement.value.slice(-2),10)||0)%24,a=(parseInt(w.minuteElement.value,10)||0)%60,i=void 0!==w.secondElement?(parseInt(w.secondElement.value,10)||0)%60:0;void 0!==w.amPM&&(e=t,n=w.amPM.textContent,t=e%12+12*r(n===w.l10n.amPM[1]));var o=void 0!==w.config.minTime||w.config.minDate&&w.minDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.minDate,!0),l=void 0!==w.config.maxTime||w.config.maxDate&&w.maxDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.maxDate,!0);if(void 0!==w.config.maxTime&&void 0!==w.config.minTime&&w.config.minTime>w.config.maxTime){var c=y(w.config.minTime.getHours(),w.config.minTime.getMinutes(),w.config.minTime.getSeconds()),s=y(w.config.maxTime.getHours(),w.config.maxTime.getMinutes(),w.config.maxTime.getSeconds()),d=y(t,a,i);if(d>s&&d<c){var u=function(e){var n=Math.floor(e/3600),t=(e-3600*n)/60;return[n,t,e-3600*n-60*t]}(c);t=u[0],a=u[1],i=u[2]}}else{if(l){var f=void 0!==w.config.maxTime?w.config.maxTime:w.config.maxDate;(t=Math.min(t,f.getHours()))===f.getHours()&&(a=Math.min(a,f.getMinutes())),a===f.getMinutes()&&(i=Math.min(i,f.getSeconds()))}if(o){var m=void 0!==w.config.minTime?w.config.minTime:w.config.minDate;(t=Math.max(t,m.getHours()))===m.getHours()&&a<m.getMinutes()&&(a=m.getMinutes()),a===m.getMinutes()&&(i=Math.max(i,m.getSeconds()))}}A(t,a,i)}}function F(e){var n=e||w.latestSelectedDateObj;n&&n instanceof Date&&A(n.getHours(),n.getMinutes(),n.getSeconds())}function A(e,n,t){void 0!==w.latestSelectedDateObj&&w.latestSelectedDateObj.setHours(e%24,n,t||0,0),w.hourElement&&w.minuteElement&&!w.isMobile&&(w.hourElement.value=o(w.config.time_24hr?e:(12+e)%12+12*r(e%12==0)),w.minuteElement.value=o(n),void 0!==w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(e>=12)]),void 0!==w.secondElement&&(w.secondElement.value=o(t)))}function N(e){var n=g(e),t=parseInt(n.value)+(e.delta||0);(t/1e3>1||"Enter"===e.key&&!/[^\d]/.test(t.toString()))&&ee(t)}function P(e,n,t,a){return n instanceof Array?n.forEach((function(n){return P(e,n,t,a)})):e instanceof Array?e.forEach((function(e){return P(e,n,t,a)})):(e.addEventListener(n,t,a),void w._handlers.push({remove:function(){return e.removeEventListener(n,t,a)}}))}function Y(){De("onChange")}function j(e,n){var t=void 0!==e?w.parseDate(e):w.latestSelectedDateObj||(w.config.minDate&&w.config.minDate>w.now?w.config.minDate:w.config.maxDate&&w.config.maxDate<w.now?w.config.maxDate:w.now),a=w.currentYear,i=w.currentMonth;try{void 0!==t&&(w.currentYear=t.getFullYear(),w.currentMonth=t.getMonth())}catch(e){e.message="Invalid date supplied: "+t,w.config.errorHandler(e)}n&&w.currentYear!==a&&(De("onYearChange"),q()),!n||w.currentYear===a&&w.currentMonth===i||De("onMonthChange"),w.redraw()}function H(e){var n=g(e);~n.className.indexOf("arrow")&&L(e,n.classList.contains("arrowUp")?1:-1)}function L(e,n,t){var a=e&&g(e),i=t||a&&a.parentNode&&a.parentNode.firstChild,o=we("increment");o.delta=n,i&&i.dispatchEvent(o)}function R(e,n,t,a){var i=ne(n,!0),o=d("span",e,n.getDate().toString());return o.dateObj=n,o.$i=a,o.setAttribute("aria-label",w.formatDate(n,w.config.ariaDateFormat)),-1===e.indexOf("hidden")&&0===M(n,w.now)&&(w.todayDateElem=o,o.classList.add("today"),o.setAttribute("aria-current","date")),i?(o.tabIndex=-1,be(n)&&(o.classList.add("selected"),w.selectedDateElem=o,"range"===w.config.mode&&(s(o,"startRange",w.selectedDates[0]&&0===M(n,w.selectedDates[0],!0)),s(o,"endRange",w.selectedDates[1]&&0===M(n,w.selectedDates[1],!0)),"nextMonthDay"===e&&o.classList.add("inRange")))):o.classList.add("flatpickr-disabled"),"range"===w.config.mode&&function(e){return!("range"!==w.config.mode||w.selectedDates.length<2)&&(M(e,w.selectedDates[0])>=0&&M(e,w.selectedDates[1])<=0)}(n)&&!be(n)&&o.classList.add("inRange"),w.weekNumbers&&1===w.config.showMonths&&"prevMonthDay"!==e&&a%7==6&&w.weekNumbers.insertAdjacentHTML("beforeend","<span class='flatpickr-day'>"+w.config.getWeek(n)+"</span>"),De("onDayCreate",o),o}function W(e){e.focus(),"range"===w.config.mode&&oe(e)}function B(e){for(var n=e>0?0:w.config.showMonths-1,t=e>0?w.config.showMonths:-1,a=n;a!=t;a+=e)for(var i=w.daysContainer.children[a],o=e>0?0:i.children.length-1,r=e>0?i.children.length:-1,l=o;l!=r;l+=e){var c=i.children[l];if(-1===c.className.indexOf("hidden")&&ne(c.dateObj))return c}}function J(e,n){var t=k(),a=te(t||document.body),i=void 0!==e?e:a?t:void 0!==w.selectedDateElem&&te(w.selectedDateElem)?w.selectedDateElem:void 0!==w.todayDateElem&&te(w.todayDateElem)?w.todayDateElem:B(n>0?1:-1);void 0===i?w._input.focus():a?function(e,n){for(var t=-1===e.className.indexOf("Month")?e.dateObj.getMonth():w.currentMonth,a=n>0?w.config.showMonths:-1,i=n>0?1:-1,o=t-w.currentMonth;o!=a;o+=i)for(var r=w.daysContainer.children[o],l=t-w.currentMonth===o?e.$i+n:n<0?r.children.length-1:0,c=r.children.length,s=l;s>=0&&s<c&&s!=(n>0?c:-1);s+=i){var d=r.children[s];if(-1===d.className.indexOf("hidden")&&ne(d.dateObj)&&Math.abs(e.$i-s)>=Math.abs(n))return W(d)}w.changeMonth(i),J(B(i),0)}(i,n):W(i)}function K(e,n){for(var t=(new Date(e,n,1).getDay()-w.l10n.firstDayOfWeek+7)%7,a=w.utils.getDaysInMonth((n-1+12)%12,e),i=w.utils.getDaysInMonth(n,e),o=window.document.createDocumentFragment(),r=w.config.showMonths>1,l=r?"prevMonthDay hidden":"prevMonthDay",c=r?"nextMonthDay hidden":"nextMonthDay",s=a+1-t,u=0;s<=a;s++,u++)o.appendChild(R("flatpickr-day "+l,new Date(e,n-1,s),0,u));for(s=1;s<=i;s++,u++)o.appendChild(R("flatpickr-day",new Date(e,n,s),0,u));for(var f=i+1;f<=42-t&&(1===w.config.showMonths||u%7!=0);f++,u++)o.appendChild(R("flatpickr-day "+c,new Date(e,n+1,f%i),0,u));var m=d("div","dayContainer");return m.appendChild(o),m}function U(){if(void 0!==w.daysContainer){u(w.daysContainer),w.weekNumbers&&u(w.weekNumbers);for(var e=document.createDocumentFragment(),n=0;n<w.config.showMonths;n++){var t=new Date(w.currentYear,w.currentMonth,1);t.setMonth(w.currentMonth+n),e.appendChild(K(t.getFullYear(),t.getMonth()))}w.daysContainer.appendChild(e),w.days=w.daysContainer.firstChild,"range"===w.config.mode&&1===w.selectedDates.length&&oe()}}function q(){if(!(w.config.showMonths>1||"dropdown"!==w.config.monthSelectorType)){var e=function(e){return!(void 0!==w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&e<w.config.minDate.getMonth())&&!(void 0!==w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()&&e>w.config.maxDate.getMonth())};w.monthsDropdownContainer.tabIndex=-1,w.monthsDropdownContainer.innerHTML="";for(var n=0;n<12;n++)if(e(n)){var t=d("option","flatpickr-monthDropdown-month");t.value=new Date(w.currentYear,n).getMonth().toString(),t.textContent=h(n,w.config.shorthandCurrentMonth,w.l10n),t.tabIndex=-1,w.currentMonth===n&&(t.selected=!0),w.monthsDropdownContainer.appendChild(t)}}}function $(){var e,n=d("div","flatpickr-month"),t=window.document.createDocumentFragment();w.config.showMonths>1||"static"===w.config.monthSelectorType?e=d("span","cur-month"):(w.monthsDropdownContainer=d("select","flatpickr-monthDropdown-months"),w.monthsDropdownContainer.setAttribute("aria-label",w.l10n.monthAriaLabel),P(w.monthsDropdownContainer,"change",(function(e){var n=g(e),t=parseInt(n.value,10);w.changeMonth(t-w.currentMonth),De("onMonthChange")})),q(),e=w.monthsDropdownContainer);var a=m("cur-year",{tabindex:"-1"}),i=a.getElementsByTagName("input")[0];i.setAttribute("aria-label",w.l10n.yearAriaLabel),w.config.minDate&&i.setAttribute("min",w.config.minDate.getFullYear().toString()),w.config.maxDate&&(i.setAttribute("max",w.config.maxDate.getFullYear().toString()),i.disabled=!!w.config.minDate&&w.config.minDate.getFullYear()===w.config.maxDate.getFullYear());var o=d("div","flatpickr-current-month");return o.appendChild(e),o.appendChild(a),t.appendChild(o),n.appendChild(t),{container:n,yearElement:i,monthElement:e}}function V(){u(w.monthNav),w.monthNav.appendChild(w.prevMonthNav),w.config.showMonths&&(w.yearElements=[],w.monthElements=[]);for(var e=w.config.showMonths;e--;){var n=$();w.yearElements.push(n.yearElement),w.monthElements.push(n.monthElement),w.monthNav.appendChild(n.container)}w.monthNav.appendChild(w.nextMonthNav)}function z(){w.weekdayContainer?u(w.weekdayContainer):w.weekdayContainer=d("div","flatpickr-weekdays");for(var e=w.config.showMonths;e--;){var n=d("div","flatpickr-weekdaycontainer");w.weekdayContainer.appendChild(n)}return G(),w.weekdayContainer}function G(){if(w.weekdayContainer){var e=w.l10n.firstDayOfWeek,t=n(w.l10n.weekdays.shorthand);e>0&&e<t.length&&(t=n(t.splice(e,t.length),t.splice(0,e)));for(var a=w.config.showMonths;a--;)w.weekdayContainer.children[a].innerHTML="\n      <span class='flatpickr-weekday'>\n        "+t.join("</span><span class='flatpickr-weekday'>")+"\n      </span>\n      "}}function Z(e,n){void 0===n&&(n=!0);var t=n?e:e-w.currentMonth;t<0&&!0===w._hidePrevMonthArrow||t>0&&!0===w._hideNextMonthArrow||(w.currentMonth+=t,(w.currentMonth<0||w.currentMonth>11)&&(w.currentYear+=w.currentMonth>11?1:-1,w.currentMonth=(w.currentMonth+12)%12,De("onYearChange"),q()),U(),De("onMonthChange"),Ce())}function Q(e){return w.calendarContainer.contains(e)}function X(e){if(w.isOpen&&!w.config.inline){var n=g(e),t=Q(n),a=!(n===w.input||n===w.altInput||w.element.contains(n)||e.path&&e.path.indexOf&&(~e.path.indexOf(w.input)||~e.path.indexOf(w.altInput)))&&!t&&!Q(e.relatedTarget),i=!w.config.ignoredFocusElements.some((function(e){return e.contains(n)}));a&&i&&(w.config.allowInput&&w.setDate(w._input.value,!1,w.config.altInput?w.config.altFormat:w.config.dateFormat),void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement&&""!==w.input.value&&void 0!==w.input.value&&_(),w.close(),w.config&&"range"===w.config.mode&&1===w.selectedDates.length&&w.clear(!1))}}function ee(e){if(!(!e||w.config.minDate&&e<w.config.minDate.getFullYear()||w.config.maxDate&&e>w.config.maxDate.getFullYear())){var n=e,t=w.currentYear!==n;w.currentYear=n||w.currentYear,w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth=Math.min(w.config.maxDate.getMonth(),w.currentMonth):w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&(w.currentMonth=Math.max(w.config.minDate.getMonth(),w.currentMonth)),t&&(w.redraw(),De("onYearChange"),q())}}function ne(e,n){var t;void 0===n&&(n=!0);var a=w.parseDate(e,void 0,n);if(w.config.minDate&&a&&M(a,w.config.minDate,void 0!==n?n:!w.minDateHasTime)<0||w.config.maxDate&&a&&M(a,w.config.maxDate,void 0!==n?n:!w.maxDateHasTime)>0)return!1;if(!w.config.enable&&0===w.config.disable.length)return!0;if(void 0===a)return!1;for(var i=!!w.config.enable,o=null!==(t=w.config.enable)&&void 0!==t?t:w.config.disable,r=0,l=void 0;r<o.length;r++){if("function"==typeof(l=o[r])&&l(a))return i;if(l instanceof Date&&void 0!==a&&l.getTime()===a.getTime())return i;if("string"==typeof l){var c=w.parseDate(l,void 0,!0);return c&&c.getTime()===a.getTime()?i:!i}if("object"==typeof l&&void 0!==a&&l.from&&l.to&&a.getTime()>=l.from.getTime()&&a.getTime()<=l.to.getTime())return i}return!i}function te(e){return void 0!==w.daysContainer&&(-1===e.className.indexOf("hidden")&&-1===e.className.indexOf("flatpickr-disabled")&&w.daysContainer.contains(e))}function ae(e){var n=e.target===w._input,t=w._input.value.trimEnd()!==Me();!n||!t||e.relatedTarget&&Q(e.relatedTarget)||w.setDate(w._input.value,!0,e.target===w.altInput?w.config.altFormat:w.config.dateFormat)}function ie(e){var n=g(e),t=w.config.wrap?p.contains(n):n===w._input,a=w.config.allowInput,i=w.isOpen&&(!a||!t),o=w.config.inline&&t&&!a;if(13===e.keyCode&&t){if(a)return w.setDate(w._input.value,!0,n===w.altInput?w.config.altFormat:w.config.dateFormat),w.close(),n.blur();w.open()}else if(Q(n)||i||o){var r=!!w.timeContainer&&w.timeContainer.contains(n);switch(e.keyCode){case 13:r?(e.preventDefault(),_(),fe()):me(e);break;case 27:e.preventDefault(),fe();break;case 8:case 46:t&&!w.config.allowInput&&(e.preventDefault(),w.clear());break;case 37:case 39:if(r||t)w.hourElement&&w.hourElement.focus();else{e.preventDefault();var l=k();if(void 0!==w.daysContainer&&(!1===a||l&&te(l))){var c=39===e.keyCode?1:-1;e.ctrlKey?(e.stopPropagation(),Z(c),J(B(1),0)):J(void 0,c)}}break;case 38:case 40:e.preventDefault();var s=40===e.keyCode?1:-1;w.daysContainer&&void 0!==n.$i||n===w.input||n===w.altInput?e.ctrlKey?(e.stopPropagation(),ee(w.currentYear-s),J(B(1),0)):r||J(void 0,7*s):n===w.currentYearElement?ee(w.currentYear-s):w.config.enableTime&&(!r&&w.hourElement&&w.hourElement.focus(),_(e),w._debouncedChange());break;case 9:if(r){var d=[w.hourElement,w.minuteElement,w.secondElement,w.amPM].concat(w.pluginElements).filter((function(e){return e})),u=d.indexOf(n);if(-1!==u){var f=d[u+(e.shiftKey?-1:1)];e.preventDefault(),(f||w._input).focus()}}else!w.config.noCalendar&&w.daysContainer&&w.daysContainer.contains(n)&&e.shiftKey&&(e.preventDefault(),w._input.focus())}}if(void 0!==w.amPM&&n===w.amPM)switch(e.key){case w.l10n.amPM[0].charAt(0):case w.l10n.amPM[0].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[0],O(),ye();break;case w.l10n.amPM[1].charAt(0):case w.l10n.amPM[1].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[1],O(),ye()}(t||Q(n))&&De("onKeyDown",e)}function oe(e,n){if(void 0===n&&(n="flatpickr-day"),1===w.selectedDates.length&&(!e||e.classList.contains(n)&&!e.classList.contains("flatpickr-disabled"))){for(var t=e?e.dateObj.getTime():w.days.firstElementChild.dateObj.getTime(),a=w.parseDate(w.selectedDates[0],void 0,!0).getTime(),i=Math.min(t,w.selectedDates[0].getTime()),o=Math.max(t,w.selectedDates[0].getTime()),r=!1,l=0,c=0,s=i;s<o;s+=x)ne(new Date(s),!0)||(r=r||s>i&&s<o,s<a&&(!l||s>l)?l=s:s>a&&(!c||s<c)&&(c=s));Array.from(w.rContainer.querySelectorAll("*:nth-child(-n+"+w.config.showMonths+") > ."+n)).forEach((function(n){var i,o,s,d=n.dateObj.getTime(),u=l>0&&d<l||c>0&&d>c;if(u)return n.classList.add("notAllowed"),void["inRange","startRange","endRange"].forEach((function(e){n.classList.remove(e)}));r&&!u||(["startRange","inRange","endRange","notAllowed"].forEach((function(e){n.classList.remove(e)})),void 0!==e&&(e.classList.add(t<=w.selectedDates[0].getTime()?"startRange":"endRange"),a<t&&d===a?n.classList.add("startRange"):a>t&&d===a&&n.classList.add("endRange"),d>=l&&(0===c||d<=c)&&(o=a,s=t,(i=d)>Math.min(o,s)&&i<Math.max(o,s))&&n.classList.add("inRange")))}))}}function re(){!w.isOpen||w.config.static||w.config.inline||de()}function le(e){return function(n){var t=w.config["_"+e+"Date"]=w.parseDate(n,w.config.dateFormat),a=w.config["_"+("min"===e?"max":"min")+"Date"];void 0!==t&&(w["min"===e?"minDateHasTime":"maxDateHasTime"]=t.getHours()>0||t.getMinutes()>0||t.getSeconds()>0),w.selectedDates&&(w.selectedDates=w.selectedDates.filter((function(e){return ne(e)})),w.selectedDates.length||"min"!==e||F(t),ye()),w.daysContainer&&(ue(),void 0!==t?w.currentYearElement[e]=t.getFullYear().toString():w.currentYearElement.removeAttribute(e),w.currentYearElement.disabled=!!a&&void 0!==t&&a.getFullYear()===t.getFullYear())}}function ce(){return w.config.wrap?p.querySelector("[data-input]"):p}function se(){"object"!=typeof w.config.locale&&void 0===I.l10ns[w.config.locale]&&w.config.errorHandler(new Error("flatpickr: invalid locale "+w.config.locale)),w.l10n=e(e({},I.l10ns.default),"object"==typeof w.config.locale?w.config.locale:"default"!==w.config.locale?I.l10ns[w.config.locale]:void 0),D.D="("+w.l10n.weekdays.shorthand.join("|")+")",D.l="("+w.l10n.weekdays.longhand.join("|")+")",D.M="("+w.l10n.months.shorthand.join("|")+")",D.F="("+w.l10n.months.longhand.join("|")+")",D.K="("+w.l10n.amPM[0]+"|"+w.l10n.amPM[1]+"|"+w.l10n.amPM[0].toLowerCase()+"|"+w.l10n.amPM[1].toLowerCase()+")",void 0===e(e({},v),JSON.parse(JSON.stringify(p.dataset||{}))).time_24hr&&void 0===I.defaultConfig.time_24hr&&(w.config.time_24hr=w.l10n.time_24hr),w.formatDate=b(w),w.parseDate=C({config:w.config,l10n:w.l10n})}function de(e){if("function"!=typeof w.config.position){if(void 0!==w.calendarContainer){De("onPreCalendarPosition");var n=e||w._positionElement,t=Array.prototype.reduce.call(w.calendarContainer.children,(function(e,n){return e+n.offsetHeight}),0),a=w.calendarContainer.offsetWidth,i=w.config.position.split(" "),o=i[0],r=i.length>1?i[1]:null,l=n.getBoundingClientRect(),c=window.innerHeight-l.bottom,d="above"===o||"below"!==o&&c<t&&l.top>t,u=window.pageYOffset+l.top+(d?-t-2:n.offsetHeight+2);if(s(w.calendarContainer,"arrowTop",!d),s(w.calendarContainer,"arrowBottom",d),!w.config.inline){var f=window.pageXOffset+l.left,m=!1,g=!1;"center"===r?(f-=(a-l.width)/2,m=!0):"right"===r&&(f-=a-l.width,g=!0),s(w.calendarContainer,"arrowLeft",!m&&!g),s(w.calendarContainer,"arrowCenter",m),s(w.calendarContainer,"arrowRight",g);var p=window.document.body.offsetWidth-(window.pageXOffset+l.right),h=f+a>window.document.body.offsetWidth,v=p+a>window.document.body.offsetWidth;if(s(w.calendarContainer,"rightMost",h),!w.config.static)if(w.calendarContainer.style.top=u+"px",h)if(v){var D=function(){for(var e=null,n=0;n<document.styleSheets.length;n++){var t=document.styleSheets[n];if(t.cssRules){try{t.cssRules}catch(e){continue}e=t;break}}return null!=e?e:(a=document.createElement("style"),document.head.appendChild(a),a.sheet);var a}();if(void 0===D)return;var b=window.document.body.offsetWidth,C=Math.max(0,b/2-a/2),M=D.cssRules.length,y="{left:"+l.left+"px;right:auto;}";s(w.calendarContainer,"rightMost",!1),s(w.calendarContainer,"centerMost",!0),D.insertRule(".flatpickr-calendar.centerMost:before,.flatpickr-calendar.centerMost:after"+y,M),w.calendarContainer.style.left=C+"px",w.calendarContainer.style.right="auto"}else w.calendarContainer.style.left="auto",w.calendarContainer.style.right=p+"px";else w.calendarContainer.style.left=f+"px",w.calendarContainer.style.right="auto"}}}else w.config.position(w,e)}function ue(){w.config.noCalendar||w.isMobile||(q(),Ce(),U())}function fe(){w._input.focus(),-1!==window.navigator.userAgent.indexOf("MSIE")||void 0!==navigator.msMaxTouchPoints?setTimeout(w.close,0):w.close()}function me(e){e.preventDefault(),e.stopPropagation();var n=f(g(e),(function(e){return e.classList&&e.classList.contains("flatpickr-day")&&!e.classList.contains("flatpickr-disabled")&&!e.classList.contains("notAllowed")}));if(void 0!==n){var t=n,a=w.latestSelectedDateObj=new Date(t.dateObj.getTime()),i=(a.getMonth()<w.currentMonth||a.getMonth()>w.currentMonth+w.config.showMonths-1)&&"range"!==w.config.mode;if(w.selectedDateElem=t,"single"===w.config.mode)w.selectedDates=[a];else if("multiple"===w.config.mode){var o=be(a);o?w.selectedDates.splice(parseInt(o),1):w.selectedDates.push(a)}else"range"===w.config.mode&&(2===w.selectedDates.length&&w.clear(!1,!1),w.latestSelectedDateObj=a,w.selectedDates.push(a),0!==M(a,w.selectedDates[0],!0)&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()})));if(O(),i){var r=w.currentYear!==a.getFullYear();w.currentYear=a.getFullYear(),w.currentMonth=a.getMonth(),r&&(De("onYearChange"),q()),De("onMonthChange")}if(Ce(),U(),ye(),i||"range"===w.config.mode||1!==w.config.showMonths?void 0!==w.selectedDateElem&&void 0===w.hourElement&&w.selectedDateElem&&w.selectedDateElem.focus():W(t),void 0!==w.hourElement&&void 0!==w.hourElement&&w.hourElement.focus(),w.config.closeOnSelect){var l="single"===w.config.mode&&!w.config.enableTime,c="range"===w.config.mode&&2===w.selectedDates.length&&!w.config.enableTime;(l||c)&&fe()}Y()}}w.parseDate=C({config:w.config,l10n:w.l10n}),w._handlers=[],w.pluginElements=[],w.loadedPlugins=[],w._bind=P,w._setHoursFromDate=F,w._positionCalendar=de,w.changeMonth=Z,w.changeYear=ee,w.clear=function(e,n){void 0===e&&(e=!0);void 0===n&&(n=!0);w.input.value="",void 0!==w.altInput&&(w.altInput.value="");void 0!==w.mobileInput&&(w.mobileInput.value="");w.selectedDates=[],w.latestSelectedDateObj=void 0,!0===n&&(w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth());if(!0===w.config.enableTime){var t=E(w.config),a=t.hours,i=t.minutes,o=t.seconds;A(a,i,o)}w.redraw(),e&&De("onChange")},w.close=function(){w.isOpen=!1,w.isMobile||(void 0!==w.calendarContainer&&w.calendarContainer.classList.remove("open"),void 0!==w._input&&w._input.classList.remove("active"));De("onClose")},w.onMouseOver=oe,w._createElement=d,w.createDay=R,w.destroy=function(){void 0!==w.config&&De("onDestroy");for(var e=w._handlers.length;e--;)w._handlers[e].remove();if(w._handlers=[],w.mobileInput)w.mobileInput.parentNode&&w.mobileInput.parentNode.removeChild(w.mobileInput),w.mobileInput=void 0;else if(w.calendarContainer&&w.calendarContainer.parentNode)if(w.config.static&&w.calendarContainer.parentNode){var n=w.calendarContainer.parentNode;if(n.lastChild&&n.removeChild(n.lastChild),n.parentNode){for(;n.firstChild;)n.parentNode.insertBefore(n.firstChild,n);n.parentNode.removeChild(n)}}else w.calendarContainer.parentNode.removeChild(w.calendarContainer);w.altInput&&(w.input.type="text",w.altInput.parentNode&&w.altInput.parentNode.removeChild(w.altInput),delete w.altInput);w.input&&(w.input.type=w.input._type,w.input.classList.remove("flatpickr-input"),w.input.removeAttribute("readonly"));["_showTimeInput","latestSelectedDateObj","_hideNextMonthArrow","_hidePrevMonthArrow","__hideNextMonthArrow","__hidePrevMonthArrow","isMobile","isOpen","selectedDateElem","minDateHasTime","maxDateHasTime","days","daysContainer","_input","_positionElement","innerContainer","rContainer","monthNav","todayDateElem","calendarContainer","weekdayContainer","prevMonthNav","nextMonthNav","monthsDropdownContainer","currentMonthElement","currentYearElement","navigationCurrentMonth","selectedDateElem","config"].forEach((function(e){try{delete w[e]}catch(e){}}))},w.isEnabled=ne,w.jumpToDate=j,w.updateValue=ye,w.open=function(e,n){void 0===n&&(n=w._positionElement);if(!0===w.isMobile){if(e){e.preventDefault();var t=g(e);t&&t.blur()}return void 0!==w.mobileInput&&(w.mobileInput.focus(),w.mobileInput.click()),void De("onOpen")}if(w._input.disabled||w.config.inline)return;var a=w.isOpen;w.isOpen=!0,a||(w.calendarContainer.classList.add("open"),w._input.classList.add("active"),De("onOpen"),de(n));!0===w.config.enableTime&&!0===w.config.noCalendar&&(!1!==w.config.allowInput||void 0!==e&&w.timeContainer.contains(e.relatedTarget)||setTimeout((function(){return w.hourElement.select()}),50))},w.redraw=ue,w.set=function(e,n){if(null!==e&&"object"==typeof e)for(var a in Object.assign(w.config,e),e)void 0!==ge[a]&&ge[a].forEach((function(e){return e()}));else w.config[e]=n,void 0!==ge[e]?ge[e].forEach((function(e){return e()})):t.indexOf(e)>-1&&(w.config[e]=c(n));w.redraw(),ye(!0)},w.setDate=function(e,n,t){void 0===n&&(n=!1);void 0===t&&(t=w.config.dateFormat);if(0!==e&&!e||e instanceof Array&&0===e.length)return w.clear(n);pe(e,t),w.latestSelectedDateObj=w.selectedDates[w.selectedDates.length-1],w.redraw(),j(void 0,n),F(),0===w.selectedDates.length&&w.clear(!1);ye(n),n&&De("onChange")},w.toggle=function(e){if(!0===w.isOpen)return w.close();w.open(e)};var ge={locale:[se,G],showMonths:[V,S,z],minDate:[j],maxDate:[j],positionElement:[ve],clickOpens:[function(){!0===w.config.clickOpens?(P(w._input,"focus",w.open),P(w._input,"click",w.open)):(w._input.removeEventListener("focus",w.open),w._input.removeEventListener("click",w.open))}]};function pe(e,n){var t=[];if(e instanceof Array)t=e.map((function(e){return w.parseDate(e,n)}));else if(e instanceof Date||"number"==typeof e)t=[w.parseDate(e,n)];else if("string"==typeof e)switch(w.config.mode){case"single":case"time":t=[w.parseDate(e,n)];break;case"multiple":t=e.split(w.config.conjunction).map((function(e){return w.parseDate(e,n)}));break;case"range":t=e.split(w.l10n.rangeSeparator).map((function(e){return w.parseDate(e,n)}))}else w.config.errorHandler(new Error("Invalid date supplied: "+JSON.stringify(e)));w.selectedDates=w.config.allowInvalidPreload?t:t.filter((function(e){return e instanceof Date&&ne(e,!1)})),"range"===w.config.mode&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()}))}function he(e){return e.slice().map((function(e){return"string"==typeof e||"number"==typeof e||e instanceof Date?w.parseDate(e,void 0,!0):e&&"object"==typeof e&&e.from&&e.to?{from:w.parseDate(e.from,void 0),to:w.parseDate(e.to,void 0)}:e})).filter((function(e){return e}))}function ve(){w._positionElement=w.config.positionElement||w._input}function De(e,n){if(void 0!==w.config){var t=w.config[e];if(void 0!==t&&t.length>0)for(var a=0;t[a]&&a<t.length;a++)t[a](w.selectedDates,w.input.value,w,n);"onChange"===e&&(w.input.dispatchEvent(we("change")),w.input.dispatchEvent(we("input")))}}function we(e){var n=document.createEvent("Event");return n.initEvent(e,!0,!0),n}function be(e){for(var n=0;n<w.selectedDates.length;n++){var t=w.selectedDates[n];if(t instanceof Date&&0===M(t,e))return""+n}return!1}function Ce(){w.config.noCalendar||w.isMobile||!w.monthNav||(w.yearElements.forEach((function(e,n){var t=new Date(w.currentYear,w.currentMonth,1);t.setMonth(w.currentMonth+n),w.config.showMonths>1||"static"===w.config.monthSelectorType?w.monthElements[n].textContent=h(t.getMonth(),w.config.shorthandCurrentMonth,w.l10n)+" ":w.monthsDropdownContainer.value=t.getMonth().toString(),e.value=t.getFullYear().toString()})),w._hidePrevMonthArrow=void 0!==w.config.minDate&&(w.currentYear===w.config.minDate.getFullYear()?w.currentMonth<=w.config.minDate.getMonth():w.currentYear<w.config.minDate.getFullYear()),w._hideNextMonthArrow=void 0!==w.config.maxDate&&(w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth+1>w.config.maxDate.getMonth():w.currentYear>w.config.maxDate.getFullYear()))}function Me(e){var n=e||(w.config.altInput?w.config.altFormat:w.config.dateFormat);return w.selectedDates.map((function(e){return w.formatDate(e,n)})).filter((function(e,n,t){return"range"!==w.config.mode||w.config.enableTime||t.indexOf(e)===n})).join("range"!==w.config.mode?w.config.conjunction:w.l10n.rangeSeparator)}function ye(e){void 0===e&&(e=!0),void 0!==w.mobileInput&&w.mobileFormatStr&&(w.mobileInput.value=void 0!==w.latestSelectedDateObj?w.formatDate(w.latestSelectedDateObj,w.mobileFormatStr):""),w.input.value=Me(w.config.dateFormat),void 0!==w.altInput&&(w.altInput.value=Me(w.config.altFormat)),!1!==e&&De("onValueUpdate")}function xe(e){var n=g(e),t=w.prevMonthNav.contains(n),a=w.nextMonthNav.contains(n);t||a?Z(t?-1:1):w.yearElements.indexOf(n)>=0?n.select():n.classList.contains("arrowUp")?w.changeYear(w.currentYear+1):n.classList.contains("arrowDown")&&w.changeYear(w.currentYear-1)}return function(){w.element=w.input=p,w.isOpen=!1,function(){var n=["wrap","weekNumbers","allowInput","allowInvalidPreload","clickOpens","time_24hr","enableTime","noCalendar","altInput","shorthandCurrentMonth","inline","static","enableSeconds","disableMobile"],i=e(e({},JSON.parse(JSON.stringify(p.dataset||{}))),v),o={};w.config.parseDate=i.parseDate,w.config.formatDate=i.formatDate,Object.defineProperty(w.config,"enable",{get:function(){return w.config._enable},set:function(e){w.config._enable=he(e)}}),Object.defineProperty(w.config,"disable",{get:function(){return w.config._disable},set:function(e){w.config._disable=he(e)}});var r="time"===i.mode;if(!i.dateFormat&&(i.enableTime||r)){var l=I.defaultConfig.dateFormat||a.dateFormat;o.dateFormat=i.noCalendar||r?"H:i"+(i.enableSeconds?":S":""):l+" H:i"+(i.enableSeconds?":S":"")}if(i.altInput&&(i.enableTime||r)&&!i.altFormat){var s=I.defaultConfig.altFormat||a.altFormat;o.altFormat=i.noCalendar||r?"h:i"+(i.enableSeconds?":S K":" K"):s+" h:i"+(i.enableSeconds?":S":"")+" K"}Object.defineProperty(w.config,"minDate",{get:function(){return w.config._minDate},set:le("min")}),Object.defineProperty(w.config,"maxDate",{get:function(){return w.config._maxDate},set:le("max")});var d=function(e){return function(n){w.config["min"===e?"_minTime":"_maxTime"]=w.parseDate(n,"H:i:S")}};Object.defineProperty(w.config,"minTime",{get:function(){return w.config._minTime},set:d("min")}),Object.defineProperty(w.config,"maxTime",{get:function(){return w.config._maxTime},set:d("max")}),"time"===i.mode&&(w.config.noCalendar=!0,w.config.enableTime=!0);Object.assign(w.config,o,i);for(var u=0;u<n.length;u++)w.config[n[u]]=!0===w.config[n[u]]||"true"===w.config[n[u]];t.filter((function(e){return void 0!==w.config[e]})).forEach((function(e){w.config[e]=c(w.config[e]||[]).map(T)})),w.isMobile=!w.config.disableMobile&&!w.config.inline&&"single"===w.config.mode&&!w.config.disable.length&&!w.config.enable&&!w.config.weekNumbers&&/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);for(u=0;u<w.config.plugins.length;u++){var f=w.config.plugins[u](w)||{};for(var m in f)t.indexOf(m)>-1?w.config[m]=c(f[m]).map(T).concat(w.config[m]):void 0===i[m]&&(w.config[m]=f[m])}i.altInputClass||(w.config.altInputClass=ce().className+" "+w.config.altInputClass);De("onParseConfig")}(),se(),function(){if(w.input=ce(),!w.input)return void w.config.errorHandler(new Error("Invalid input element specified"));w.input._type=w.input.type,w.input.type="text",w.input.classList.add("flatpickr-input"),w._input=w.input,w.config.altInput&&(w.altInput=d(w.input.nodeName,w.config.altInputClass),w._input=w.altInput,w.altInput.placeholder=w.input.placeholder,w.altInput.disabled=w.input.disabled,w.altInput.required=w.input.required,w.altInput.tabIndex=w.input.tabIndex,w.altInput.type="text",w.input.setAttribute("type","hidden"),!w.config.static&&w.input.parentNode&&w.input.parentNode.insertBefore(w.altInput,w.input.nextSibling));w.config.allowInput||w._input.setAttribute("readonly","readonly");ve()}(),function(){w.selectedDates=[],w.now=w.parseDate(w.config.now)||new Date;var e=w.config.defaultDate||("INPUT"!==w.input.nodeName&&"TEXTAREA"!==w.input.nodeName||!w.input.placeholder||w.input.value!==w.input.placeholder?w.input.value:null);e&&pe(e,w.config.dateFormat);w._initialDate=w.selectedDates.length>0?w.selectedDates[0]:w.config.minDate&&w.config.minDate.getTime()>w.now.getTime()?w.config.minDate:w.config.maxDate&&w.config.maxDate.getTime()<w.now.getTime()?w.config.maxDate:w.now,w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth(),w.selectedDates.length>0&&(w.latestSelectedDateObj=w.selectedDates[0]);void 0!==w.config.minTime&&(w.config.minTime=w.parseDate(w.config.minTime,"H:i"));void 0!==w.config.maxTime&&(w.config.maxTime=w.parseDate(w.config.maxTime,"H:i"));w.minDateHasTime=!!w.config.minDate&&(w.config.minDate.getHours()>0||w.config.minDate.getMinutes()>0||w.config.minDate.getSeconds()>0),w.maxDateHasTime=!!w.config.maxDate&&(w.config.maxDate.getHours()>0||w.config.maxDate.getMinutes()>0||w.config.maxDate.getSeconds()>0)}(),w.utils={getDaysInMonth:function(e,n){return void 0===e&&(e=w.currentMonth),void 0===n&&(n=w.currentYear),1===e&&(n%4==0&&n%100!=0||n%400==0)?29:w.l10n.daysInMonth[e]}},w.isMobile||function(){var e=window.document.createDocumentFragment();if(w.calendarContainer=d("div","flatpickr-calendar"),w.calendarContainer.tabIndex=-1,!w.config.noCalendar){if(e.appendChild((w.monthNav=d("div","flatpickr-months"),w.yearElements=[],w.monthElements=[],w.prevMonthNav=d("span","flatpickr-prev-month"),w.prevMonthNav.innerHTML=w.config.prevArrow,w.nextMonthNav=d("span","flatpickr-next-month"),w.nextMonthNav.innerHTML=w.config.nextArrow,V(),Object.defineProperty(w,"_hidePrevMonthArrow",{get:function(){return w.__hidePrevMonthArrow},set:function(e){w.__hidePrevMonthArrow!==e&&(s(w.prevMonthNav,"flatpickr-disabled",e),w.__hidePrevMonthArrow=e)}}),Object.defineProperty(w,"_hideNextMonthArrow",{get:function(){return w.__hideNextMonthArrow},set:function(e){w.__hideNextMonthArrow!==e&&(s(w.nextMonthNav,"flatpickr-disabled",e),w.__hideNextMonthArrow=e)}}),w.currentYearElement=w.yearElements[0],Ce(),w.monthNav)),w.innerContainer=d("div","flatpickr-innerContainer"),w.config.weekNumbers){var n=function(){w.calendarContainer.classList.add("hasWeeks");var e=d("div","flatpickr-weekwrapper");e.appendChild(d("span","flatpickr-weekday",w.l10n.weekAbbreviation));var n=d("div","flatpickr-weeks");return e.appendChild(n),{weekWrapper:e,weekNumbers:n}}(),t=n.weekWrapper,a=n.weekNumbers;w.innerContainer.appendChild(t),w.weekNumbers=a,w.weekWrapper=t}w.rContainer=d("div","flatpickr-rContainer"),w.rContainer.appendChild(z()),w.daysContainer||(w.daysContainer=d("div","flatpickr-days"),w.daysContainer.tabIndex=-1),U(),w.rContainer.appendChild(w.daysContainer),w.innerContainer.appendChild(w.rContainer),e.appendChild(w.innerContainer)}w.config.enableTime&&e.appendChild(function(){w.calendarContainer.classList.add("hasTime"),w.config.noCalendar&&w.calendarContainer.classList.add("noCalendar");var e=E(w.config);w.timeContainer=d("div","flatpickr-time"),w.timeContainer.tabIndex=-1;var n=d("span","flatpickr-time-separator",":"),t=m("flatpickr-hour",{"aria-label":w.l10n.hourAriaLabel});w.hourElement=t.getElementsByTagName("input")[0];var a=m("flatpickr-minute",{"aria-label":w.l10n.minuteAriaLabel});w.minuteElement=a.getElementsByTagName("input")[0],w.hourElement.tabIndex=w.minuteElement.tabIndex=-1,w.hourElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getHours():w.config.time_24hr?e.hours:function(e){switch(e%24){case 0:case 12:return 12;default:return e%12}}(e.hours)),w.minuteElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getMinutes():e.minutes),w.hourElement.setAttribute("step",w.config.hourIncrement.toString()),w.minuteElement.setAttribute("step",w.config.minuteIncrement.toString()),w.hourElement.setAttribute("min",w.config.time_24hr?"0":"1"),w.hourElement.setAttribute("max",w.config.time_24hr?"23":"12"),w.hourElement.setAttribute("maxlength","2"),w.minuteElement.setAttribute("min","0"),w.minuteElement.setAttribute("max","59"),w.minuteElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(t),w.timeContainer.appendChild(n),w.timeContainer.appendChild(a),w.config.time_24hr&&w.timeContainer.classList.add("time24hr");if(w.config.enableSeconds){w.timeContainer.classList.add("hasSeconds");var i=m("flatpickr-second");w.secondElement=i.getElementsByTagName("input")[0],w.secondElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getSeconds():e.seconds),w.secondElement.setAttribute("step",w.minuteElement.getAttribute("step")),w.secondElement.setAttribute("min","0"),w.secondElement.setAttribute("max","59"),w.secondElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(d("span","flatpickr-time-separator",":")),w.timeContainer.appendChild(i)}w.config.time_24hr||(w.amPM=d("span","flatpickr-am-pm",w.l10n.amPM[r((w.latestSelectedDateObj?w.hourElement.value:w.config.defaultHour)>11)]),w.amPM.title=w.l10n.toggleTitle,w.amPM.tabIndex=-1,w.timeContainer.appendChild(w.amPM));return w.timeContainer}());s(w.calendarContainer,"rangeMode","range"===w.config.mode),s(w.calendarContainer,"animate",!0===w.config.animate),s(w.calendarContainer,"multiMonth",w.config.showMonths>1),w.calendarContainer.appendChild(e);var i=void 0!==w.config.appendTo&&void 0!==w.config.appendTo.nodeType;if((w.config.inline||w.config.static)&&(w.calendarContainer.classList.add(w.config.inline?"inline":"static"),w.config.inline&&(!i&&w.element.parentNode?w.element.parentNode.insertBefore(w.calendarContainer,w._input.nextSibling):void 0!==w.config.appendTo&&w.config.appendTo.appendChild(w.calendarContainer)),w.config.static)){var l=d("div","flatpickr-wrapper");w.element.parentNode&&w.element.parentNode.insertBefore(l,w.element),l.appendChild(w.element),w.altInput&&l.appendChild(w.altInput),l.appendChild(w.calendarContainer)}w.config.static||w.config.inline||(void 0!==w.config.appendTo?w.config.appendTo:window.document.body).appendChild(w.calendarContainer)}(),function(){w.config.wrap&&["open","close","toggle","clear"].forEach((function(e){Array.prototype.forEach.call(w.element.querySelectorAll("[data-"+e+"]"),(function(n){return P(n,"click",w[e])}))}));if(w.isMobile)return void function(){var e=w.config.enableTime?w.config.noCalendar?"time":"datetime-local":"date";w.mobileInput=d("input",w.input.className+" flatpickr-mobile"),w.mobileInput.tabIndex=1,w.mobileInput.type=e,w.mobileInput.disabled=w.input.disabled,w.mobileInput.required=w.input.required,w.mobileInput.placeholder=w.input.placeholder,w.mobileFormatStr="datetime-local"===e?"Y-m-d\\TH:i:S":"date"===e?"Y-m-d":"H:i:S",w.selectedDates.length>0&&(w.mobileInput.defaultValue=w.mobileInput.value=w.formatDate(w.selectedDates[0],w.mobileFormatStr));w.config.minDate&&(w.mobileInput.min=w.formatDate(w.config.minDate,"Y-m-d"));w.config.maxDate&&(w.mobileInput.max=w.formatDate(w.config.maxDate,"Y-m-d"));w.input.getAttribute("step")&&(w.mobileInput.step=String(w.input.getAttribute("step")));w.input.type="hidden",void 0!==w.altInput&&(w.altInput.type="hidden");try{w.input.parentNode&&w.input.parentNode.insertBefore(w.mobileInput,w.input.nextSibling)}catch(e){}P(w.mobileInput,"change",(function(e){w.setDate(g(e).value,!1,w.mobileFormatStr),De("onChange"),De("onClose")}))}();var e=l(re,50);w._debouncedChange=l(Y,300),w.daysContainer&&!/iPhone|iPad|iPod/i.test(navigator.userAgent)&&P(w.daysContainer,"mouseover",(function(e){"range"===w.config.mode&&oe(g(e))}));P(w._input,"keydown",ie),void 0!==w.calendarContainer&&P(w.calendarContainer,"keydown",ie);w.config.inline||w.config.static||P(window,"resize",e);void 0!==window.ontouchstart?P(window.document,"touchstart",X):P(window.document,"mousedown",X);P(window.document,"focus",X,{capture:!0}),!0===w.config.clickOpens&&(P(w._input,"focus",w.open),P(w._input,"click",w.open));void 0!==w.daysContainer&&(P(w.monthNav,"click",xe),P(w.monthNav,["keyup","increment"],N),P(w.daysContainer,"click",me));if(void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement){var n=function(e){return g(e).select()};P(w.timeContainer,["increment"],_),P(w.timeContainer,"blur",_,{capture:!0}),P(w.timeContainer,"click",H),P([w.hourElement,w.minuteElement],["focus","click"],n),void 0!==w.secondElement&&P(w.secondElement,"focus",(function(){return w.secondElement&&w.secondElement.select()})),void 0!==w.amPM&&P(w.amPM,"click",(function(e){_(e)}))}w.config.allowInput&&P(w._input,"blur",ae)}(),(w.selectedDates.length||w.config.noCalendar)&&(w.config.enableTime&&F(w.config.noCalendar?w.latestSelectedDateObj:void 0),ye(!1)),S();var n=/^((?!chrome|android).)*safari/i.test(navigator.userAgent);!w.isMobile&&n&&de(),De("onReady")}(),w}function T(e,n){for(var t=Array.prototype.slice.call(e).filter((function(e){return e instanceof HTMLElement})),a=[],i=0;i<t.length;i++){var o=t[i];try{if(null!==o.getAttribute("data-fp-omit"))continue;void 0!==o._flatpickr&&(o._flatpickr.destroy(),o._flatpickr=void 0),o._flatpickr=k(o,n||{}),a.push(o._flatpickr)}catch(e){console.error(e)}}return 1===a.length?a[0]:a}"undefined"!=typeof HTMLElement&&"undefined"!=typeof HTMLCollection&&"undefined"!=typeof NodeList&&(HTMLCollection.prototype.flatpickr=NodeList.prototype.flatpickr=function(e){return T(this,e)},HTMLElement.prototype.flatpickr=function(e){return T([this],e)});var I=function(e,n){return"string"==typeof e?T(window.document.querySelectorAll(e),n):e instanceof Node?T([e],n):T(e,n)};return I.defaultConfig={},I.l10ns={en:e({},i),default:e({},i)},I.localize=function(n){I.l10ns.default=e(e({},I.l10ns.default),n)},I.setDefaults=function(n){I.defaultConfig=e(e({},I.defaultConfig),n)},I.parseDate=C({}),I.formatDate=b({}),I.compareDates=M,"undefined"!=typeof jQuery&&void 0!==jQuery.fn&&(jQuery.fn.flatpickr=function(e){return T(this,e)}),Date.prototype.fp_incr=function(e){return new Date(this.getFullYear(),this.getMonth(),this.getDate()+("string"==typeof e?parseInt(e,10):e))},"undefined"!=typeof window&&(window.flatpickr=I),I}));
Index: /navarro-booking-for-woocommerce/tags/2.5.0/docs/extending.md
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/docs/extending.md	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/docs/extending.md	(revision 3750436)
@@ -0,0 +1,116 @@
+# Extending Navarro Booking
+
+Navarro Booking is complete on its own. Add-ons extend it only through the actions and filters
+below, the same way any WordPress plugin is extended. There is no licence check, no
+disabled code path and no "pro" flag anywhere in this plugin.
+
+All instants are UTC strings, `Y-m-d H:i:s`. Calendar dates and wall-clock times are in
+the site timezone (`wp_timezone()`).
+
+## How a booking flows
+
+```
+product page ──GET /wp-json/calentra/v1/availability──▶ Calentra_Availability::get()
+add to cart  ──Calentra_Availability::validate()──▶ Calentra_Bookings::create_hold()   (atomic claim)
+cart/checkout views ─▶ hold extended, or re-claimed if it lapsed and the slot is free
+order placed ─▶ Calentra_Bookings::confirm()   hold → confirmed, or the order fails before payment
+cancelled / failed / refunded / trashed ─▶ Calentra_Bookings::release()
+```
+
+Capacity is counted per **unit**: the slot start for a timeslot booking, and each night
+(local midnight) for a multi-day booking. A claim is a conditional `UPDATE` on that
+unit's counter, so it either takes the seats or affects no row; there is no window in
+which two customers can both take the last seat. `tests/concurrency/run.sh` demonstrates
+this.
+
+### Resources shared across products
+
+A booking on a resource (`resource_id > 0`) is counted twice: on the product's own
+counter, as above, and on the resource's shared counter (inventory rows with
+`product_id = 0`), which every product using that resource draws from. A claim takes the
+resource's seats first and the product's after, all or nothing. So the resource's
+capacity is one pool across all its products, and a product's own capacity on it only
+caps what that product may take.
+
+- The shared counter is keyed on five-minute granules covering the booking, not on slot
+  starts, so products with different slot grids still collide when they overlap
+  (18:00–19:00 and 18:15–19:30). Times off the five-minute grid are rounded outwards,
+  which can refuse two bookings that only touch, never oversell.
+- Multi-day bookings use their nights on the shared counter. A resource must not be
+  shared between a multi-day product and a timeslot product: the two are counted in
+  different units and would not see each other. An extension attaching resources must
+  refuse that combination (Navarro Booking Pro does).
+- A booking on a resource may span at most 400 granules (about 33 hours); a longer one is
+  refused with `calentra_too_long`.
+- Availability on a resource is the lower of what the product has left and what the
+  resource has left across every product.
+- `calentra_slot_capacity` is called with `$product_id = 0` for the shared counter. Return
+  the resource's whole capacity there.
+
+## Filters
+
+| Filter | Arguments | Use |
+|---|---|---|
+| `calentra_slot_capacity` | `int $capacity` (1), `int $product_id`, `int $resource_id`, `string $start_utc` | Seats per slot or night. Raise it for classes, tours, rooms with several beds. With `$product_id = 0` and a resource, it asks for the resource's whole capacity across every product (its shared counter). |
+| `calentra_product_config` | `array $config`, `int $product_id` | Change a product's effective rules: `start`, `end`, `duration`, `blocked_dates`, `blocked_slots`, `max_days_ahead`, `type`. |
+| `calentra_generate_slots` | `array $slots` (`[start, end]` `DateTimeImmutable` pairs, site tz), `int $product_id`, `string $date`, `array $config` | Replace or edit the slot grid for one date — weekly schedules, buffers, per-weekday hours. |
+| `calentra_availability` | `array $result`, `int $product_id`, `array $args` | Post-process computed availability (the REST response body). |
+| `calentra_validate_booking` | `array\|WP_Error $booking`, `int $product_id`, `array $request` | Refuse a booking (return `WP_Error`) or add data to it. Runs on add to cart and checkout. |
+| `calentra_booking_resource_id` | `int $resource_id` (0), `int $product_id`, `array $fields` | Book against a resource (staff member, room) instead of the product. A resource is counted once across every product that offers it (see "Resources shared across products"), and each product's own capacity on it caps that product's share. |
+| `calentra_booking_price` | `float $price`, `array $cart_item`, `WC_Product $product` | Price per unit of quantity. Multi-day defaults to price × nights. Deposits, seasonal pricing. |
+| `calentra_hold_duration` | `int $seconds`, `int $product_id` | How long add-to-cart reserves a slot. Default from settings (60 minutes). |
+| `calentra_ics_email_ids` | `string[] $ids` | Which WooCommerce emails get an `.ics` attachment. |
+| `calentra_claim_resources` | `int[] $resources` (the requested one), `int $product_id`, `array $booking` | Resources to try, in order, for a new claim. Core claims each atomically and the first with room wins; the booking records the winner. Pool staff or rooms with this — never by reading availability and then claiming. Capped at 50. A re-claimed lapsed hold carries `previous_resource_id` in `$booking`. |
+| `calentra_ics_summary` | `string $summary` (item name), `object $booking` | Title of the `.ics` invite. |
+| `calentra_resource_label` | `string $label` ("#id"), `int $resource_id`, `object $booking` | How a resource is named in WooCommerce → Bookings. Core has no resource names of its own. |
+| `calentra_feature_unlocked` | `bool $unlocked` (false), `string $id` | Return true for a feature your extension provides, and its locked teaser disappears everywhere. Ids are the keys of `Calentra_Upgrade::features()`; today only `resources`. |
+| `calentra_upgrade_url` | `string $url` (the `calentra_upgrade_url` option, empty by default) | Where "learn more" on a locked feature goes. While empty, locks explain the feature and show no link. |
+
+## Actions
+
+| Action | Arguments | Fires |
+|---|---|---|
+| `calentra_hold_created` | `int $booking_id`, `array $booking` | A customer added a booking to their cart and the seats were claimed. |
+| `calentra_booking_confirmed` | `int $booking_id`, `int $order_id` | A hold became a confirmed booking on an order (or an admin created one). |
+| `calentra_booking_released` | `int $booking_id`, `string $reason`, `object $row` | Seats were given back: `expired`, `removed_from_cart`, `checkout_failed`, `order_woocommerce_order_status_cancelled`, etc. |
+| `calentra_claim_failed` | `int $product_id`, `array $booking` | A customer lost the race for a slot. |
+| `calentra_product_options_end` | `WP_Post $post` | End of the product's booking options box — add fields here. |
+| `calentra_settings_fields` | — | End of the global settings form — add settings here. |
+
+## Example: a class with 12 places
+
+```php
+add_filter('calentra_slot_capacity', function ($capacity, $product_id) {
+    $places = (int) get_post_meta($product_id, '_my_class_places', true);
+    return $places > 0 ? $places : $capacity;
+}, 10, 2);
+```
+
+Customers can now book quantity > 1, the picker shows remaining places
+(`seats` in the REST response), and the concurrency guarantees hold for any capacity.
+
+## Example: closed on Mondays
+
+```php
+add_filter('calentra_generate_slots', function ($slots, $product_id, $date) {
+    return (new DateTimeImmutable($date))->format('N') === '1' ? [] : $slots;
+}, 10, 3);
+```
+
+## REST
+
+`GET /wp-json/calentra/v1/availability?product_id=ID&from=YYYY-MM-DD&to=YYYY-MM-DD[&resource_id=N]`
+
+Public, no nonce, at most 62 days per request. `resource_id` (default 0, the product
+itself) asks about one resource; it is echoed back as `resource_id` in every response,
+and each resource is cached separately. Core does not check that the resource belongs
+to the product. An extension that assigns resources should do that, for example in
+`calentra_validate_booking`, so that only attached resources can be booked.
+
+The product-page picker sends `resource_id` when the add-to-cart form contains a field
+named `calentra_resource_id` (a `<select>`, say), and reloads availability when it
+changes. Keep that field static markup so cached product pages keep working. Timeslot products return
+`days[date] = [{start, end, local, seats}]` (only bookable slots); multi-day products
+return `nights[date] = seats left`. Responses carry an `ETag` and
+`Cache-Control: public, max-age=30`; a request carrying a WooCommerce cart cookie gets a
+private response in which that cart's own holds count as free.
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-admin.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-admin.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-admin.php	(revision 3750436)
@@ -0,0 +1,516 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+class Calentra_Admin {
+
+    const NONCE_ACTION = 'calentra_save_product';
+    const NONCE_FIELD = 'calentra_product_nonce';
+
+    public function init_hooks() {
+        add_action('add_meta_boxes', [$this, 'add_booking_meta_box']);
+        add_action('save_post_product', [$this, 'save_booking_data'], 10, 2);
+        add_action('admin_notices', [$this, 'show_save_errors']);
+        add_action('admin_menu', [$this, 'register_settings_menu']);
+        add_action('admin_init', [$this, 'register_settings']);
+        add_action('admin_post_calentra_rebuild_inventory', [$this, 'rebuild_inventory']);
+        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
+        add_action('woocommerce_after_order_itemmeta', [$this, 'editable_fields_for_booking'], 10, 3);
+        add_action('woocommerce_before_save_order_items', [$this, 'save_fields_for_booking'], 10, 2);
+        foreach (['calentra_global_start', 'calentra_global_end', 'calentra_global_blocked_dates', 'calentra_global_blocked_slots', 'calentra_max_days_ahead'] as $option) {
+            // The first save of an option goes through add_option(), which fires
+            // add_option_*, not update_option_*, so both are needed.
+            add_action("add_option_{$option}", [__CLASS__, 'bump_global_cache_version']);
+            add_action("update_option_{$option}", [__CLASS__, 'bump_global_cache_version']);
+        }
+    }
+
+    public static function bump_global_cache_version() {
+        update_option('calentra_global_cache_version', (int) get_option('calentra_global_cache_version') + 1);
+    }
+
+    public function enqueue_admin_scripts($hook) {
+        $screen = get_current_screen();
+        $ours = $screen && (
+            $screen->id === 'product'
+            || $screen->id === 'shop_order'
+            || $screen->id === 'woocommerce_page_wc-orders'
+            || $screen->id === 'woocommerce_page_calentra-settings'
+        );
+        if (!$ours) {
+            return;
+        }
+        wp_enqueue_style('flatpickr', CALENTRA_PLUGIN_URL . 'assets/css/flatpickr.min.css', [], calentra_asset_version('assets/css/flatpickr.min.css'));
+        wp_enqueue_script('flatpickr', CALENTRA_PLUGIN_URL . 'assets/js/flatpickr.min.js', [], calentra_asset_version('assets/js/flatpickr.min.js'), true);
+        wp_enqueue_script('calentra-admin-ui', CALENTRA_PLUGIN_URL . 'assets/js/admin-ui.js', ['jquery', 'flatpickr'], calentra_asset_version('assets/js/admin-ui.js'), true);
+
+        // The diagnostics tab has assets of its own, and only that tab needs them.
+        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading which tab is being displayed, not acting on it.
+        if ($screen->id === 'woocommerce_page_calentra-settings' && isset($_GET['tab']) && $_GET['tab'] === 'diagnostics') {
+            Calentra_Diagnostics::enqueue();
+        }
+    }
+
+    // ── Per-product booking meta box ───────────────────────────────────────────
+
+    public function add_booking_meta_box() {
+        add_meta_box(
+            'calentra_booking_meta',
+            __('Navarro Booking Options', 'navarro-booking-for-woocommerce'),
+            [$this, 'render_booking_meta_box'],
+            'product',
+            'normal'
+        );
+    }
+
+    public function render_booking_meta_box($post) {
+        $enabled = get_post_meta($post->ID, '_calentra_booking_enabled', true);
+        $start = get_post_meta($post->ID, '_calentra_start_time', true);
+        $end = get_post_meta($post->ID, '_calentra_end_time', true);
+        $duration = get_post_meta($post->ID, '_calentra_duration', true);
+        $days_ahead = get_post_meta($post->ID, '_calentra_max_days_ahead', true);
+        $blocked_dates = Calentra_Rules::parse_dates(get_post_meta($post->ID, '_calentra_blocked_dates', true))['valid'];
+        $blocked_slots = Calentra_Rules::parse_slots(get_post_meta($post->ID, '_calentra_blocked_slots', true))['valid'];
+
+        wp_nonce_field(self::NONCE_ACTION, self::NONCE_FIELD);
+        ?>
+        <p><label><input type="checkbox" name="calentra_booking_enabled" value="1" <?php checked('1', (string) $enabled); ?>>
+            <?php esc_html_e('Enable Booking', 'navarro-booking-for-woocommerce'); ?></label></p>
+        <?php
+        woocommerce_wp_checkbox([
+            'id'          => '_calentra_hide_quantity',
+            'label'       => __('Hide Quantity Field', 'navarro-booking-for-woocommerce'),
+            'desc_tip'    => true,
+            'description' => __('Hide the quantity selector on the product page for this booking.', 'navarro-booking-for-woocommerce'),
+        ]);
+        woocommerce_wp_select([
+            'id'          => '_calentra_booking_type',
+            'label'       => __('Booking Type', 'navarro-booking-for-woocommerce'),
+            'options'     => [
+                'timeslot'  => __('Timeslot', 'navarro-booking-for-woocommerce'),
+                'multi-day' => __('Multi-Day (priced per night)', 'navarro-booking-for-woocommerce'),
+            ],
+            'description' => __('Time slots within opening hours, or a range of nights from a start date to an end date.', 'navarro-booking-for-woocommerce'),
+            'desc_tip'    => true,
+        ]);
+        ?>
+        <p><label for="calentra_start_time"><?php esc_html_e('Start Time (e.g. 09:00)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="time" id="calentra_start_time" name="calentra_start_time" value="<?php echo esc_attr($start); ?>"></p>
+        <p><label for="calentra_end_time"><?php esc_html_e('End Time (e.g. 17:00)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="time" id="calentra_end_time" name="calentra_end_time" value="<?php echo esc_attr($end); ?>"></p>
+        <?php Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_PRODUCT_TIMES); ?>
+        <p><label for="calentra_duration"><?php esc_html_e('Booking Duration (minutes)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="number" id="calentra_duration" name="calentra_duration" min="1" max="1440" value="<?php echo esc_attr($duration); ?>"></p>
+        <?php Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_PRODUCT_CAPACITY); ?>
+        <p><label for="calentra_max_days_ahead"><?php esc_html_e('Bookable up to (days ahead)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="number" id="calentra_max_days_ahead" name="calentra_max_days_ahead" min="1" max="<?php echo (int) Calentra_Rules::MAX_DAYS_AHEAD; ?>" value="<?php echo esc_attr($days_ahead); ?>"
+                placeholder="<?php echo esc_attr((string) Calentra_Rules::max_days_ahead(0)); ?>">
+            <span class="description"><?php esc_html_e('Leave empty to use the global setting.', 'navarro-booking-for-woocommerce'); ?></span></p>
+        <p><label for="calentra_blocked_dates"><?php esc_html_e('Blocked Dates (YYYY-MM-DD)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="text" id="calentra_blocked_dates" name="calentra_blocked_dates" class="calentra-blocked-dates" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_dates)); ?>"></p>
+        <p><label for="calentra_blocked_slots"><?php esc_html_e('Blocked Time Slots (YYYY-MM-DD HH:MM)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="text" id="calentra_blocked_slots" name="calentra_blocked_slots" class="calentra-blocked-slots" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_slots)); ?>"></p>
+        <p class="description"><?php
+            /* translators: %s: timezone name */
+            printf(esc_html__('Times are in the site timezone (%s).', 'navarro-booking-for-woocommerce'), esc_html(wp_timezone_string()));
+        ?></p>
+        <?php
+        /**
+         * Fires at the end of the product's booking options, for add-ons to add fields.
+         *
+         * @param WP_Post $post The product.
+         */
+        do_action('calentra_product_options_end', $post);
+        Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_PRODUCT_END);
+    }
+
+    public function save_booking_data($post_id, $post) {
+        if (!isset($_POST[self::NONCE_FIELD])
+            || !wp_verify_nonce(sanitize_key(wp_unslash($_POST[self::NONCE_FIELD])), self::NONCE_ACTION)) {
+            return;
+        }
+        if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
+            return;
+        }
+        if (!current_user_can('edit_post', $post_id)) {
+            return;
+        }
+
+        $post_value = function ($key) {
+            return isset($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
+        };
+        $errors = [];
+
+        update_post_meta($post_id, '_calentra_booking_enabled', $post_value('calentra_booking_enabled') === '1' ? '1' : '0');
+        update_post_meta($post_id, '_calentra_hide_quantity', $post_value('_calentra_hide_quantity') ? 'yes' : 'no');
+        update_post_meta($post_id, '_calentra_booking_type', $post_value('_calentra_booking_type') === 'multi-day' ? 'multi-day' : 'timeslot');
+
+        $start = $post_value('calentra_start_time');
+        $end = $post_value('calentra_end_time');
+        foreach (['_calentra_start_time' => $start, '_calentra_end_time' => $end] as $key => $value) {
+            if ($value !== '' && !Calentra_Rules::valid_time($value)) {
+                /* translators: %s: the invalid value */
+                $errors[] = sprintf(__('"%s" is not a valid time (use HH:MM).', 'navarro-booking-for-woocommerce'), $value);
+                continue;
+            }
+            update_post_meta($post_id, $key, $value);
+        }
+        if ($start && $end && Calentra_Rules::valid_time($start) && Calentra_Rules::valid_time($end) && $end <= $start) {
+            $errors[] = __('The end time must be after the start time; no slots will be offered until this is fixed.', 'navarro-booking-for-woocommerce');
+        }
+
+        $duration = (int) $post_value('calentra_duration');
+        update_post_meta($post_id, '_calentra_duration', $duration > 0 ? min($duration, 1440) : '');
+        $days_ahead = Calentra_Rules::clamp_days_ahead($post_value('calentra_max_days_ahead'));
+        update_post_meta($post_id, '_calentra_max_days_ahead', $days_ahead ?: '');
+
+        $dates = Calentra_Rules::parse_dates($post_value('calentra_blocked_dates'));
+        $slots = Calentra_Rules::parse_slots($post_value('calentra_blocked_slots'));
+        update_post_meta($post_id, '_calentra_blocked_dates', $dates['valid']);
+        update_post_meta($post_id, '_calentra_blocked_slots', $slots['valid']);
+        if ($dates['invalid']) {
+            /* translators: %s: list of rejected entries */
+            $errors[] = sprintf(__('These blocked dates were not understood and were ignored: %s. Use YYYY-MM-DD.', 'navarro-booking-for-woocommerce'), implode(', ', $dates['invalid']));
+        }
+        if ($slots['invalid']) {
+            /* translators: %s: list of rejected entries */
+            $errors[] = sprintf(__('These blocked time slots were not understood and were ignored: %s. Use YYYY-MM-DD HH:MM.', 'navarro-booking-for-woocommerce'), implode(', ', $slots['invalid']));
+        }
+
+        Calentra_Availability::bump_cache_version($post_id);
+        if ($errors) {
+            set_transient('calentra_save_errors_' . get_current_user_id(), $errors, 60);
+        }
+    }
+
+    public function show_save_errors() {
+        $key = 'calentra_save_errors_' . get_current_user_id();
+        $errors = get_transient($key);
+        if (!$errors) {
+            return;
+        }
+        delete_transient($key);
+        echo '<div class="notice notice-error"><p><strong>' . esc_html__('Navarro Booking', 'navarro-booking-for-woocommerce') . '</strong></p><ul>';
+        foreach ((array) $errors as $error) {
+            echo '<li>' . esc_html($error) . '</li>';
+        }
+        echo '</ul></div>';
+    }
+
+    // ── Order item booking editor ─────────────────────────────────────────────
+
+    public function editable_fields_for_booking($item_id, $item, $product) {
+        if (!$item->is_type('line_item') || !current_user_can('edit_shop_orders')) {
+            return;
+        }
+        $booking = Calentra_Bookings::get((int) $item->get_meta('_calentra_booking_id'));
+        $config = Calentra_Rules::product_config($item->get_product_id());
+        if (!$booking && !$config['enabled']) {
+            return;
+        }
+        $type = $booking ? $booking->booking_type : $config['type'];
+        $tz = $booking ? Calentra_Display::tz($booking->site_timezone) : wp_timezone();
+        $start = $booking ? strtotime($booking->start_utc . ' UTC') : 0;
+        $end = $booking ? strtotime($booking->end_utc . ' UTC') : 0;
+
+        echo '<div class="calentra-order-booking edit_booking_fields">';
+        if ($booking) {
+            echo '<p><strong>' . esc_html__('Booking', 'navarro-booking-for-woocommerce') . ' #' . (int) $booking->id . '</strong> — ' . esc_html(ucfirst($booking->status));
+            echo ' · ' . esc_html(Calentra_Display::time_with_zone($start, $tz)) . ' ' . esc_html(wp_date(get_option('date_format'), $start, $tz));
+            echo ' · <code>' . esc_html($booking->start_utc) . ' UTC</code></p>';
+        }
+        woocommerce_wp_text_input([
+            'id'            => "calentra_booking_date_{$item_id}",
+            'name'          => "calentra_booking_date[{$item_id}]",
+            'label'         => __('Booking Date', 'navarro-booking-for-woocommerce'),
+            'value'         => $start ? wp_date('Y-m-d', $start, $tz) : '',
+            'wrapper_class' => 'form-field',
+            'class'         => 'calentra-flatpickr-date',
+        ]);
+        if ($type === 'multi-day') {
+            woocommerce_wp_text_input([
+                'id'            => "calentra_booking_end_date_{$item_id}",
+                'name'          => "calentra_booking_end_date[{$item_id}]",
+                'label'         => __('End Date', 'navarro-booking-for-woocommerce'),
+                'value'         => $end ? wp_date('Y-m-d', $end, $tz) : '',
+                'wrapper_class' => 'form-field',
+                'class'         => 'calentra-flatpickr-date',
+            ]);
+        } else {
+            woocommerce_wp_text_input([
+                'id'            => "calentra_booking_time_{$item_id}",
+                'name'          => "calentra_booking_time[{$item_id}]",
+                /* translators: %s: timezone */
+                'label'         => sprintf(__('Booking Time (%s)', 'navarro-booking-for-woocommerce'), $tz->getName()),
+                'value'         => $start ? wp_date('H:i', $start, $tz) : '',
+                'wrapper_class' => 'form-field',
+                'class'         => 'calentra-flatpickr-time',
+            ]);
+        }
+        echo '</div>';
+    }
+
+    /**
+     * Saves booking edits from the order screen. WooCommerce has already verified its own
+     * nonce on both routes that reach this hook (the order form and the "Save" items AJAX
+     * call); we add the capability check and read only from the items WooCommerce passes.
+     */
+    public function save_fields_for_booking($order_id, $items) {
+        if (!current_user_can('edit_shop_orders') || empty($items['calentra_booking_date']) || !is_array($items['calentra_booking_date'])) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+
+        foreach ($items['calentra_booking_date'] as $item_id => $date) {
+            $item = $order->get_item((int) $item_id);
+            if (!$item || !$item->is_type('line_item')) {
+                continue;
+            }
+            $date = Calentra_Rules::valid_date(sanitize_text_field(wp_unslash($date)));
+            $time = Calentra_Rules::valid_time(sanitize_text_field(wp_unslash($items['calentra_booking_time'][$item_id] ?? '')));
+            $end_date = Calentra_Rules::valid_date(sanitize_text_field(wp_unslash($items['calentra_booking_end_date'][$item_id] ?? '')));
+            if (!$date) {
+                continue;
+            }
+
+            $booking = Calentra_Bookings::get((int) $item->get_meta('_calentra_booking_id'));
+            $config = Calentra_Rules::product_config($item->get_product_id());
+            $type = $booking ? $booking->booking_type : $config['type'];
+            $tz = $booking ? Calentra_Display::tz($booking->site_timezone) : wp_timezone();
+
+            try {
+                if ($type === 'multi-day') {
+                    if (!$end_date || $end_date <= $date) {
+                        continue;
+                    }
+                    $start = new DateTimeImmutable($date, $tz);
+                    $end = new DateTimeImmutable($end_date, $tz);
+                } else {
+                    if (!$time) {
+                        continue;
+                    }
+                    $start = new DateTimeImmutable("$date $time", $tz);
+                    $length = $booking ? strtotime($booking->end_utc) - strtotime($booking->start_utc) : $config['duration'] * 60;
+                    $end = $start->modify('+' . (int) $length . ' seconds');
+                }
+            } catch (Exception $e) {
+                continue;
+            }
+
+            $new = [
+                'type'        => $type,
+                'start_utc'   => gmdate(Calentra_Availability::UTC_FORMAT, $start->getTimestamp()),
+                'end_utc'     => gmdate(Calentra_Availability::UTC_FORMAT, $end->getTimestamp()),
+                'timezone'    => $tz->getName(),
+                'seats'       => max(1, (int) $item->get_quantity()),
+                'resource_id' => $booking ? (int) $booking->resource_id : 0,
+            ];
+            if ($booking && $booking->start_utc === $new['start_utc'] && $booking->end_utc === $new['end_utc']) {
+                continue;
+            }
+
+            // Admins may book outside opening hours, but never over capacity.
+            if ($booking) {
+                $result = Calentra_Bookings::move($booking->id, $new);
+            } else {
+                $result = Calentra_Bookings::create_confirmed($item->get_product_id(), $new, $order_id, $item->get_id());
+                if (!is_wp_error($result)) {
+                    $item->update_meta_data('_calentra_booking_id', $result);
+                }
+            }
+
+            if (is_wp_error($result)) {
+                $order->add_order_note(sprintf(
+                    /* translators: 1: product name, 2: error */
+                    __('Navarro Booking: booking for %1$s was not changed. %2$s', 'navarro-booking-for-woocommerce'),
+                    $item->get_name(),
+                    $result->get_error_message()
+                ));
+                continue;
+            }
+            Calentra_Display::write_item_meta($item, $new);
+            $item->save();
+            $order->add_order_note(sprintf(
+                /* translators: 1: product name, 2: new date and time */
+                __('Navarro Booking: booking for %1$s moved to %2$s.', 'navarro-booking-for-woocommerce'),
+                $item->get_name(),
+                implode(' ', Calentra_Display::lines($new))
+            ));
+        }
+    }
+
+    // ── Global settings + diagnostics ─────────────────────────────────────────
+
+    public function register_settings_menu() {
+        add_submenu_page(
+            'woocommerce',
+            __('Bookings', 'navarro-booking-for-woocommerce'),
+            __('Bookings', 'navarro-booking-for-woocommerce'),
+            'manage_woocommerce',
+            'calentra-bookings',
+            function () {
+                // Loaded only here: WP_List_Table has no business on any other request.
+                require_once CALENTRA_PLUGIN_PATH . 'includes/class-calentra-bookings-list.php';
+                Calentra_Bookings_List::render_page();
+            }
+        );
+        add_submenu_page(
+            'woocommerce',
+            __('Navarro Booking Settings', 'navarro-booking-for-woocommerce'),
+            __('Navarro Booking Settings', 'navarro-booking-for-woocommerce'),
+            'manage_woocommerce',
+            'calentra-settings',
+            [$this, 'render_settings_page']
+        );
+    }
+
+    public function register_settings() {
+        register_setting('calentra_settings', 'calentra_global_start', [
+            'type'              => 'string',
+            'sanitize_callback' => function ($v) {
+                return $this->sanitize_time_setting($v, 'calentra_global_start', '09:00');
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_global_end', [
+            'type'              => 'string',
+            'sanitize_callback' => function ($v) {
+                return $this->sanitize_time_setting($v, 'calentra_global_end', '17:00');
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_global_blocked_dates', [
+            'type'              => 'array',
+            'sanitize_callback' => function ($v) {
+                $parsed = Calentra_Rules::parse_dates($v);
+                if ($parsed['invalid']) {
+                    /* translators: %s: rejected entries */
+                    add_settings_error('calentra_global_blocked_dates', 'invalid', sprintf(__('These blocked dates were not understood and were ignored: %s. Use YYYY-MM-DD.', 'navarro-booking-for-woocommerce'), implode(', ', $parsed['invalid'])));
+                }
+                return $parsed['valid'];
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_global_blocked_slots', [
+            'type'              => 'array',
+            'sanitize_callback' => function ($v) {
+                $parsed = Calentra_Rules::parse_slots($v);
+                if ($parsed['invalid']) {
+                    /* translators: %s: rejected entries */
+                    add_settings_error('calentra_global_blocked_slots', 'invalid', sprintf(__('These blocked time slots were not understood and were ignored: %s. Use YYYY-MM-DD HH:MM.', 'navarro-booking-for-woocommerce'), implode(', ', $parsed['invalid'])));
+                }
+                return $parsed['valid'];
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_hold_minutes', [
+            'type'              => 'integer',
+            'default'           => 60,
+            'sanitize_callback' => function ($v) {
+                return min(1440, max(5, absint($v) ?: 60));
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_max_days_ahead', [
+            'type'              => 'integer',
+            'default'           => Calentra_Rules::DEFAULT_DAYS_AHEAD,
+            'sanitize_callback' => function ($v) {
+                return Calentra_Rules::clamp_days_ahead($v) ?: Calentra_Rules::DEFAULT_DAYS_AHEAD;
+            },
+        ]);
+    }
+
+    private function sanitize_time_setting($value, $option, $fallback) {
+        $value = sanitize_text_field((string) $value);
+        if (Calentra_Rules::valid_time($value)) {
+            return $value;
+        }
+        /* translators: %s: the invalid value */
+        add_settings_error($option, 'invalid', sprintf(__('"%s" is not a valid time (use HH:MM).', 'navarro-booking-for-woocommerce'), $value));
+        return get_option($option, $fallback);
+    }
+
+    public function render_settings_page() {
+        if (!current_user_can('manage_woocommerce')) {
+            return;
+        }
+        $tab = isset($_GET['tab']) && $_GET['tab'] === 'diagnostics' ? 'diagnostics' : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+        $base = admin_url('admin.php?page=calentra-settings');
+        ?>
+        <div class="wrap">
+            <h1><?php esc_html_e('Navarro Booking', 'navarro-booking-for-woocommerce'); ?></h1>
+            <nav class="nav-tab-wrapper">
+                <a href="<?php echo esc_url($base); ?>" class="nav-tab <?php echo $tab === 'settings' ? 'nav-tab-active' : ''; ?>"><?php esc_html_e('Settings', 'navarro-booking-for-woocommerce'); ?></a>
+                <a href="<?php echo esc_url(add_query_arg('tab', 'diagnostics', $base)); ?>" class="nav-tab <?php echo $tab === 'diagnostics' ? 'nav-tab-active' : ''; ?>"><?php esc_html_e('Timezone diagnostics', 'navarro-booking-for-woocommerce'); ?></a>
+            </nav>
+            <?php
+            if ($tab === 'diagnostics') {
+                (new Calentra_Diagnostics())->render();
+            } else {
+                $this->render_settings_form();
+            }
+            ?>
+        </div>
+        <?php
+    }
+
+    private function render_settings_form() {
+        $blocked_dates = Calentra_Rules::parse_dates(get_option('calentra_global_blocked_dates', []))['valid'];
+        $blocked_slots = Calentra_Rules::parse_slots(get_option('calentra_global_blocked_slots', []))['valid'];
+        settings_errors();
+        ?>
+        <form method="post" action="options.php">
+            <?php settings_fields('calentra_settings'); ?>
+            <table class="form-table" role="presentation">
+                <tr>
+                    <th scope="row"><label for="calentra_global_start"><?php esc_html_e('Global Start Time', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="time" id="calentra_global_start" name="calentra_global_start" value="<?php echo esc_attr(get_option('calentra_global_start', '09:00')); ?>"></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_global_end"><?php esc_html_e('Global End Time', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="time" id="calentra_global_end" name="calentra_global_end" value="<?php echo esc_attr(get_option('calentra_global_end', '17:00')); ?>"></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_global_blocked_dates"><?php esc_html_e('Blocked Dates', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="text" id="calentra_global_blocked_dates" name="calentra_global_blocked_dates" class="calentra-blocked-dates" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_dates)); ?>">
+                        <p class="description"><?php esc_html_e('Comma-separated YYYY-MM-DD', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_global_blocked_slots"><?php esc_html_e('Blocked Time Slots', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="text" id="calentra_global_blocked_slots" name="calentra_global_blocked_slots" class="calentra-blocked-slots" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_slots)); ?>">
+                        <p class="description"><?php esc_html_e('Comma-separated YYYY-MM-DD HH:MM', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_hold_minutes"><?php esc_html_e('Cart hold (minutes)', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="number" min="5" max="1440" id="calentra_hold_minutes" name="calentra_hold_minutes" value="<?php echo esc_attr((int) get_option('calentra_hold_minutes', 60)); ?>">
+                        <p class="description"><?php esc_html_e('How long a slot stays reserved for a customer after they add it to their cart. The hold is refreshed while they are active in the cart or checkout. If it lapses and someone else books the slot, checkout is refused before payment.', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_max_days_ahead"><?php esc_html_e('Bookable up to (days ahead)', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="number" min="1" max="<?php echo (int) Calentra_Rules::MAX_DAYS_AHEAD; ?>" id="calentra_max_days_ahead" name="calentra_max_days_ahead" value="<?php echo esc_attr((string) Calentra_Rules::max_days_ahead(0)); ?>">
+                        <p class="description"><?php esc_html_e('How far ahead customers can book. Products can set their own limit.', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+            </table>
+            <p class="description"><?php
+                /* translators: %s: timezone name */
+                printf(esc_html__('All times are in the site timezone (%s), set under Settings → General.', 'navarro-booking-for-woocommerce'), esc_html(wp_timezone_string()));
+            ?></p>
+            <?php
+            Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_SETTINGS);
+            /** Fires after the built-in global settings, for add-ons to add their own. */
+            do_action('calentra_settings_fields');
+            submit_button();
+            ?>
+        </form>
+        <?php
+        Calentra_Upgrade::settings_footer();
+    }
+
+    public function rebuild_inventory() {
+        if (!current_user_can('manage_woocommerce')) {
+            wp_die(esc_html__('You do not have permission to do that.', 'navarro-booking-for-woocommerce'));
+        }
+        check_admin_referer('calentra_rebuild_inventory');
+        Calentra_Install::rebuild_inventory();
+        wp_safe_redirect(add_query_arg(['page' => 'calentra-settings', 'tab' => 'diagnostics', 'rebuilt' => 1], admin_url('admin.php')));
+        exit;
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-availability.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-availability.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-availability.php	(revision 3750436)
@@ -0,0 +1,447 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * The single availability authority.
+ *
+ * The REST read endpoint, add-to-cart validation and checkout re-validation all call
+ * this class, so a slot that the picker would not offer can never be bought.
+ *
+ * All instants are UTC ("Y-m-d H:i:s"). Calendar dates and wall-clock times are in the
+ * site timezone, because that is the timezone the merchant configures opening hours in.
+ */
+class Calentra_Availability {
+
+    const UTC_FORMAT = 'Y-m-d H:i:s';
+    const MAX_RANGE_DAYS = 62;
+
+    /**
+     * Seats per bookable unit. 1 in this plugin; an add-on may raise it.
+     *
+     * @param int    $product_id
+     * @param int    $resource_id
+     * @param string $start_utc   Unit start, UTC.
+     */
+    public static function capacity($product_id, $resource_id = 0, $start_utc = '') {
+        /**
+         * Filters how many seats one slot (or one night, for multi-day) holds.
+         *
+         * @param int    $capacity    Default 1.
+         * @param int    $product_id  Product ID.
+         * @param int    $resource_id Resource ID, 0 for the product itself.
+         * @param string $start_utc   The unit's start instant in UTC.
+         */
+        return max(1, (int) apply_filters('calentra_slot_capacity', 1, (int) $product_id, (int) $resource_id, $start_utc));
+    }
+
+    /**
+     * Available slots or nights for a product over a date range (site dates, inclusive).
+     *
+     * @param int    $product_id
+     * @param string $from        Y-m-d, site timezone.
+     * @param string $to          Y-m-d, site timezone.
+     * @param array  $args        session_key: holds belonging to this cart session count as free.
+     *                            exclude_booking: a booking ID to ignore (when moving it).
+     *                            resource_id: defaults to 0.
+     * @return array|WP_Error
+     */
+    public static function get($product_id, $from, $to, array $args = []) {
+        $args = wp_parse_args($args, ['session_key' => '', 'exclude_booking' => 0, 'resource_id' => 0]);
+        $product_id = (int) $product_id;
+        $config = Calentra_Rules::product_config($product_id);
+
+        if (!$config['enabled']) {
+            return new WP_Error('calentra_not_bookable', __('This product cannot be booked.', 'navarro-booking-for-woocommerce'), ['status' => 404]);
+        }
+        $from = Calentra_Rules::valid_date($from);
+        $to = Calentra_Rules::valid_date($to);
+        if (!$from || !$to || $to < $from) {
+            return new WP_Error('calentra_bad_range', __('Invalid date range.', 'navarro-booking-for-woocommerce'), ['status' => 400]);
+        }
+
+        $tz = $config['timezone'];
+        $first = new DateTimeImmutable($from, $tz);
+        $last = new DateTimeImmutable($to, $tz);
+        if ($first->diff($last)->days > self::MAX_RANGE_DAYS) {
+            return new WP_Error('calentra_bad_range', __('Date range too long.', 'navarro-booking-for-woocommerce'), ['status' => 400]);
+        }
+
+        $range_start = $first->setTime(0, 0);
+        $range_end = $last->modify('+1 day')->setTime(0, 0);
+        $busy = self::busy_intervals($product_id, (int) $args['resource_id'], $range_start, $range_end, $args);
+        // On a resource, also what every product has booked on it: the resource's own
+        // capacity is a pool they share. Null for the product itself.
+        $resource_busy = (int) $args['resource_id'] > 0
+            ? self::resource_busy_intervals((int) $args['resource_id'], $range_start, $range_end, $args)
+            : null;
+
+        $result = [
+            'product_id'  => $product_id,
+            'resource_id' => (int) $args['resource_id'],
+            'type'        => $config['type'],
+            'timezone'    => $tz->getName(),
+            'from'        => $from,
+            'to'          => $to,
+        ];
+
+        if ($config['type'] === 'multi-day') {
+            $result['nights'] = [];
+            for ($day = $range_start; $day < $range_end; $day = $day->modify('+1 day')) {
+                $date = $day->format('Y-m-d');
+                $result['nights'][$date] = self::night_seats($product_id, (int) $args['resource_id'], $date, $config, $busy, $resource_busy);
+            }
+        } else {
+            $result['days'] = [];
+            for ($day = $range_start; $day < $range_end; $day = $day->modify('+1 day')) {
+                $date = $day->format('Y-m-d');
+                $result['days'][$date] = self::day_slots($product_id, (int) $args['resource_id'], $date, $config, $busy, $resource_busy);
+            }
+        }
+
+        /**
+         * Filters the computed availability for a product and range.
+         *
+         * @param array $result     Availability, as returned by the REST endpoint.
+         * @param int   $product_id Product ID.
+         * @param array $args       Lookup arguments.
+         */
+        return apply_filters('calentra_availability', $result, $product_id, $args);
+    }
+
+    /**
+     * Slots on one site-local date, each with remaining seats. Only slots with at least
+     * one free seat are returned.
+     */
+    private static function day_slots($product_id, $resource_id, $date, array $config, array $busy, $resource_busy = null) {
+        $out = [];
+        if (!self::date_is_open($date, $config)) {
+            return $out;
+        }
+
+        $now = time();
+        $blocked = self::blocked_ranges_for($date, $config);
+
+        foreach (self::generate_slots($product_id, $date, $config) as $slot) {
+            [$start, $end] = $slot;
+            if ($start->getTimestamp() <= $now) {
+                continue;
+            }
+            foreach ($blocked as [$bs, $be]) {
+                if ($start->getTimestamp() < $be && $bs < $end->getTimestamp()) {
+                    continue 2;
+                }
+            }
+            $start_utc = gmdate(self::UTC_FORMAT, $start->getTimestamp());
+            $seats = self::seats_left($product_id, $resource_id, $start_utc, $start->getTimestamp(), $end->getTimestamp(), $busy, $resource_busy);
+            if ($seats < 1) {
+                continue;
+            }
+            $out[] = [
+                'start' => gmdate('Y-m-d\TH:i:s\Z', $start->getTimestamp()),
+                'end'   => gmdate('Y-m-d\TH:i:s\Z', $end->getTimestamp()),
+                'local' => $start->format('H:i'),
+                'seats' => $seats,
+            ];
+        }
+        return $out;
+    }
+
+    /**
+     * Wall-clock slot grid for one date: [start, end) pairs as site-timezone DateTimes.
+     *
+     * @return array<int, array{0: DateTimeImmutable, 1: DateTimeImmutable}>
+     */
+    public static function generate_slots($product_id, $date, array $config) {
+        $tz = $config['timezone'];
+        [$sh, $sm] = array_map('intval', explode(':', $config['start']));
+        [$eh, $em] = array_map('intval', explode(':', $config['end']));
+        $day = new DateTimeImmutable($date, $tz);
+        $open = $sh * 60 + $sm;
+        $close = $eh * 60 + $em;
+        $slots = [];
+        $seen = [];
+
+        for ($m = $open; $m + $config['duration'] <= $close; $m += $config['duration']) {
+            // Built from wall-clock time, so a 09:00 class is 09:00 on both sides of a DST change.
+            $start = $day->setTime(intdiv($m, 60), $m % 60);
+            $end = $start->modify('+' . $config['duration'] . ' minutes');
+            if (isset($seen[$start->getTimestamp()])) {
+                continue; // A wall time skipped by a DST jump normalises onto its neighbour.
+            }
+            $seen[$start->getTimestamp()] = true;
+            $slots[] = [$start, $end];
+        }
+
+        /**
+         * Filters the slot grid for one date before availability is applied.
+         *
+         * @param array  $slots      [start, end] DateTimeImmutable pairs in the site timezone.
+         * @param int    $product_id Product ID.
+         * @param string $date       Site-local Y-m-d.
+         * @param array  $config     Product configuration.
+         */
+        return apply_filters('calentra_generate_slots', $slots, (int) $product_id, $date, $config);
+    }
+
+    private static function night_seats($product_id, $resource_id, $date, array $config, array $busy, $resource_busy = null) {
+        if (!self::date_is_open($date, $config)) {
+            return 0;
+        }
+        $tz = $config['timezone'];
+        $start = new DateTimeImmutable($date, $tz);
+        $end = $start->modify('+1 day');
+        $start_utc = gmdate(self::UTC_FORMAT, $start->getTimestamp());
+        $seats = self::seats_left($product_id, $resource_id, $start_utc, $start->getTimestamp(), $end->getTimestamp(), $busy, $resource_busy);
+        return max(0, $seats);
+    }
+
+    private static function date_is_open($date, array $config) {
+        $today = (new DateTimeImmutable('now', $config['timezone']))->format('Y-m-d');
+        if ($date < $today) {
+            return false;
+        }
+        $horizon = (new DateTimeImmutable('now', $config['timezone']))->modify('+' . (int) $config['max_days_ahead'] . ' days')->format('Y-m-d');
+        if ($date > $horizon) {
+            return false;
+        }
+        return !in_array($date, $config['blocked_dates'], true);
+    }
+
+    /**
+     * Blocked "YYYY-MM-DD HH:MM" entries for a date, as [start, end) timestamps.
+     * Each blocks one slot length from the given time, as in v1.1.0.
+     */
+    private static function blocked_ranges_for($date, array $config) {
+        $ranges = [];
+        foreach ($config['blocked_slots'] as $slot) {
+            if (strpos($slot, $date . ' ') !== 0) {
+                continue;
+            }
+            $start = new DateTimeImmutable($slot, $config['timezone']);
+            $ranges[] = [$start->getTimestamp(), $start->getTimestamp() + $config['duration'] * 60];
+        }
+        return $ranges;
+    }
+
+    /**
+     * Bookings that occupy seats in a range: confirmed ones and holds that have not
+     * expired. An expired hold stops counting the moment its timestamp passes, so
+     * correctness never waits on a cron job.
+     *
+     * @return array<int, array{0: int, 1: int, 2: int}> [start ts, end ts, seats]
+     */
+    private static function busy_intervals($product_id, $resource_id, DateTimeImmutable $from, DateTimeImmutable $to, array $args) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(self::UTC_FORMAT);
+
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Navarro Booking's own table; core has no API for it, and a cached read could sell a seat twice.
+        $rows = $wpdb->get_results($wpdb->prepare(
+            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is Calentra_Install::bookings_table(), a prefix plus a literal; every value below is a placeholder.
+            "SELECT id, start_utc, end_utc, seats, status, session_key FROM $table
+              WHERE product_id = %d AND resource_id = %d
+                AND start_utc < %s AND end_utc > %s
+                AND (status = 'confirmed' OR (status = 'hold' AND hold_expires >= %s))",
+            $product_id,
+            $resource_id,
+            gmdate(self::UTC_FORMAT, $to->getTimestamp()),
+            gmdate(self::UTC_FORMAT, $from->getTimestamp()),
+            $now
+        ));
+
+        $busy = [];
+        foreach ((array) $rows as $row) {
+            if ((int) $row->id === (int) $args['exclude_booking']) {
+                continue;
+            }
+            if ($row->status === 'hold' && $args['session_key'] !== '' && $row->session_key === $args['session_key']) {
+                continue; // A customer is never blocked by their own cart.
+            }
+            $busy[] = [strtotime($row->start_utc . ' UTC'), strtotime($row->end_utc . ' UTC'), (int) $row->seats];
+        }
+        return $busy;
+    }
+
+    /**
+     * Bookings on a resource from every product, as busy_intervals() returns them, with
+     * each interval rounded out to Calentra_Bookings::RESOURCE_GRANULE.
+     *
+     * @return array<int, array{0: int, 1: int, 2: int}>
+     */
+    private static function resource_busy_intervals($resource_id, DateTimeImmutable $from, DateTimeImmutable $to, array $args) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(self::UTC_FORMAT);
+        $granule = Calentra_Bookings::RESOURCE_GRANULE;
+
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Navarro Booking's own table; core has no API for it, and a cached read could sell a seat twice.
+        $rows = $wpdb->get_results($wpdb->prepare(
+            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is Calentra_Install::bookings_table(), a prefix plus a literal; every value below is a placeholder.
+            "SELECT id, start_utc, end_utc, seats, status, session_key FROM $table
+              WHERE resource_id = %d
+                AND start_utc < %s AND end_utc > %s
+                AND (status = 'confirmed' OR (status = 'hold' AND hold_expires >= %s))",
+            $resource_id,
+            gmdate(self::UTC_FORMAT, $to->getTimestamp() + $granule),
+            gmdate(self::UTC_FORMAT, $from->getTimestamp() - $granule),
+            $now
+        ));
+
+        $busy = [];
+        foreach ((array) $rows as $row) {
+            if ((int) $row->id === (int) $args['exclude_booking']) {
+                continue;
+            }
+            if ($row->status === 'hold' && $args['session_key'] !== '' && $row->session_key === $args['session_key']) {
+                continue; // A customer is never blocked by their own cart.
+            }
+            $start = strtotime($row->start_utc . ' UTC');
+            $end = strtotime($row->end_utc . ' UTC');
+            $busy[] = [intdiv($start, $granule) * $granule, intdiv($end + $granule - 1, $granule) * $granule, (int) $row->seats];
+        }
+        return $busy;
+    }
+
+    /**
+     * Seats left for [start, end): what the product may still take, and on a resource no
+     * more than the resource has left across every product.
+     *
+     * Both sum every booking that overlaps the interval rather than the peak in use at
+     * any one moment, so two bookings that overlap the slot but not each other both
+     * count. That understates what is free, never overstates it, as per-product
+     * counting always has.
+     *
+     * The resource-wide part rounds intervals out to Calentra_Bookings::RESOURCE_GRANULE,
+     * as the claim does, so a slot shown as free is not refused for sharing a granule.
+     */
+    private static function seats_left($product_id, $resource_id, $start_utc, $start, $end, array $busy, $resource_busy) {
+        $seats = self::capacity($product_id, $resource_id, $start_utc) - self::seats_overlapping($busy, $start, $end);
+        if ($resource_busy !== null) {
+            $granule = Calentra_Bookings::RESOURCE_GRANULE;
+            $pool = self::capacity(0, $resource_id, $start_utc) - self::seats_overlapping(
+                $resource_busy,
+                intdiv($start, $granule) * $granule,
+                intdiv($end + $granule - 1, $granule) * $granule
+            );
+            $seats = min($seats, $pool);
+        }
+        return $seats;
+    }
+
+    private static function seats_overlapping(array $busy, $start, $end) {
+        $used = 0;
+        foreach ($busy as [$bs, $be, $seats]) {
+            if ($bs < $end && $start < $be) {
+                $used += $seats;
+            }
+        }
+        return $used;
+    }
+
+    /**
+     * Checks a customer's requested booking against the same rules the picker uses.
+     *
+     * @param int   $product_id
+     * @param array $request    date (Y-m-d), time (H:i, timeslot), end_date (Y-m-d, multi-day), seats.
+     * @param array $args       As for get().
+     * @return array|WP_Error   On success: type, start_utc, end_utc, seats, resource_id, date, time, end_date.
+     */
+    public static function validate($product_id, array $request, array $args = []) {
+        $product_id = (int) $product_id;
+        $config = Calentra_Rules::product_config($product_id);
+        $seats = max(1, (int) ($request['seats'] ?? 1));
+        $resource_id = (int) ($args['resource_id'] ?? 0);
+        $date = Calentra_Rules::valid_date($request['date'] ?? '');
+
+        if (!$config['enabled']) {
+            return new WP_Error('calentra_not_bookable', __('This product cannot be booked.', 'navarro-booking-for-woocommerce'));
+        }
+        if (!$date) {
+            return new WP_Error('calentra_bad_date', __('Please choose a valid booking date.', 'navarro-booking-for-woocommerce'));
+        }
+
+        if ($config['type'] === 'multi-day') {
+            $end_date = Calentra_Rules::valid_date($request['end_date'] ?? '');
+            if (!$end_date || $end_date <= $date) {
+                return new WP_Error('calentra_bad_end_date', __('Please choose an end date after the start date.', 'navarro-booking-for-woocommerce'));
+            }
+            $last_night = (new DateTimeImmutable($end_date, $config['timezone']))->modify('-1 day')->format('Y-m-d');
+            $avail = self::get($product_id, $date, $last_night, $args);
+            if (is_wp_error($avail)) {
+                return $avail;
+            }
+            foreach ($avail['nights'] as $night => $free) {
+                if ($free < $seats) {
+                    return new WP_Error('calentra_unavailable', sprintf(
+                        /* translators: %s: a date */
+                        __('Sorry, %s is not available for the dates you chose. Please pick different dates.', 'navarro-booking-for-woocommerce'),
+                        $night
+                    ));
+                }
+            }
+            $start = new DateTimeImmutable($date, $config['timezone']);
+            $end = new DateTimeImmutable($end_date, $config['timezone']);
+            $booking = [
+                'type'      => 'multi-day',
+                'start_utc' => gmdate(self::UTC_FORMAT, $start->getTimestamp()),
+                'end_utc'   => gmdate(self::UTC_FORMAT, $end->getTimestamp()),
+                'date'      => $date,
+                'time'      => '',
+                'end_date'  => $end_date,
+            ];
+        } else {
+            $time = Calentra_Rules::valid_time($request['time'] ?? '');
+            if (!$time) {
+                return new WP_Error('calentra_bad_time', __('Please choose a time slot.', 'navarro-booking-for-woocommerce'));
+            }
+            $avail = self::get($product_id, $date, $date, $args);
+            if (is_wp_error($avail)) {
+                return $avail;
+            }
+            $match = null;
+            foreach ($avail['days'][$date] as $slot) {
+                if ($slot['local'] === $time) {
+                    $match = $slot;
+                    break;
+                }
+            }
+            if (!$match || $match['seats'] < $seats) {
+                return new WP_Error('calentra_unavailable', __('Sorry, that time slot is not available. Please choose another.', 'navarro-booking-for-woocommerce'));
+            }
+            $booking = [
+                'type'      => 'timeslot',
+                'start_utc' => gmdate(self::UTC_FORMAT, strtotime($match['start'])),
+                'end_utc'   => gmdate(self::UTC_FORMAT, strtotime($match['end'])),
+                'date'      => $date,
+                'time'      => $time,
+                'end_date'  => '',
+            ];
+        }
+
+        $booking['seats'] = $seats;
+        $booking['resource_id'] = $resource_id;
+        $booking['timezone'] = $config['timezone']->getName();
+
+        /**
+         * Filters the result of validating a booking request. Return a WP_Error to refuse it.
+         *
+         * @param array|WP_Error $booking    The normalised booking.
+         * @param int            $product_id Product ID.
+         * @param array          $request    The raw request.
+         */
+        return apply_filters('calentra_validate_booking', $booking, $product_id, $request);
+    }
+
+    /**
+     * Per-product cache version. Bumped whenever a booking or the product's rules change,
+     * so cached availability responses are invalidated.
+     */
+    public static function cache_version($product_id) {
+        return (int) get_post_meta($product_id, '_calentra_cache_version', true);
+    }
+
+    public static function bump_cache_version($product_id) {
+        update_post_meta($product_id, '_calentra_cache_version', self::cache_version($product_id) + 1);
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-bookings-list.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-bookings-list.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-bookings-list.php	(revision 3750436)
@@ -0,0 +1,308 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+if (!class_exists('WP_List_Table')) {
+    require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
+}
+
+/*
+ * The list reads Navarro Booking's own bookings table, which WordPress has no API for, so the
+ * direct-query and caching sniffs cannot be satisfied in this file. They are switched
+ * off for the file on the same invariant used in class-calentra-bookings.php:
+ *
+ *   the only table name is Calentra_Install::bookings_table() ($wpdb->prefix plus a
+ *   literal), passed as a %i identifier; every filter value is a %d or %s placeholder
+ *   passed through $wpdb->prepare(); ORDER BY takes only a column from a fixed
+ *   allow-list.
+ *
+ * The list is uncached because it is an admin screen that must show the current state.
+ */
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+/**
+ * Read-only list of bookings, under WooCommerce → Bookings.
+ *
+ * Filters arrive by GET and only narrow what is shown; nothing on this screen writes,
+ * so there is no nonce to check.
+ */
+class Calentra_Bookings_List extends WP_List_Table {
+
+    const PER_PAGE = 20;
+
+    const STATUSES = ['active', 'confirmed', 'hold', 'cancelled', 'expired', 'all'];
+
+    const SORTABLE = ['id' => 'id', 'start' => 'start_utc', 'product' => 'product_id'];
+
+    public function __construct() {
+        parent::__construct([
+            'singular' => 'calentra-booking',
+            'plural'   => 'calentra-bookings',
+            'ajax'     => false,
+        ]);
+    }
+
+    /**
+     * Current filters, read from the query string and normalised.
+     *
+     * @return array{product: int, status: string, from: string, to: string}
+     */
+    public static function filters() {
+        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only filters on a read-only screen.
+        $get = function ($key) {
+            return isset($_GET[$key]) ? sanitize_text_field(wp_unslash($_GET[$key])) : null;
+        };
+        $status = $get('booking_status');
+        $from = $get('from');
+        // phpcs:enable WordPress.Security.NonceVerification.Recommended
+        return [
+            'product' => absint($get('product')),
+            'status'  => in_array($status, self::STATUSES, true) ? $status : 'active',
+            // With no "from", the list starts today: upcoming bookings are what people look for.
+            'from'    => $from === null ? wp_date('Y-m-d') : Calentra_Rules::valid_date($from),
+            'to'      => Calentra_Rules::valid_date((string) $get('to')),
+        ];
+    }
+
+    public function get_columns() {
+        return [
+            'id'       => __('Booking', 'navarro-booking-for-woocommerce'),
+            'product'  => __('Product', 'navarro-booking-for-woocommerce'),
+            'resource' => __('Resource', 'navarro-booking-for-woocommerce'),
+            'start'    => __('Start', 'navarro-booking-for-woocommerce'),
+            'end'      => __('End', 'navarro-booking-for-woocommerce'),
+            'seats'    => __('Seats', 'navarro-booking-for-woocommerce'),
+            'status'   => __('Status', 'navarro-booking-for-woocommerce'),
+            'order'    => __('Order', 'navarro-booking-for-woocommerce'),
+        ];
+    }
+
+    protected function get_sortable_columns() {
+        return [
+            'id'      => ['id', false],
+            'start'   => ['start', false],
+            'product' => ['product', false],
+        ];
+    }
+
+    protected function get_primary_column_name() {
+        return 'start';
+    }
+
+    public function no_items() {
+        esc_html_e('No bookings match these filters.', 'navarro-booking-for-woocommerce');
+    }
+
+    public function prepare_items() {
+        global $wpdb;
+        $f = self::filters();
+        $tz = wp_timezone();
+        $where = ['1=1'];
+        $params = [];
+
+        if ($f['product']) {
+            $where[] = 'product_id = %d';
+            $params[] = $f['product'];
+        }
+        if ($f['status'] === 'active') {
+            // What occupies seats right now: confirmed bookings and holds that haven't lapsed.
+            $where[] = "(status = 'confirmed' OR (status = 'hold' AND hold_expires >= %s))";
+            $params[] = gmdate(Calentra_Availability::UTC_FORMAT);
+        } elseif ($f['status'] !== 'all') {
+            $where[] = 'status = %s';
+            $params[] = $f['status'];
+        }
+        if ($f['from']) {
+            $where[] = 'end_utc > %s';
+            $params[] = gmdate(Calentra_Availability::UTC_FORMAT, (new DateTimeImmutable($f['from'], $tz))->getTimestamp());
+        }
+        if ($f['to']) {
+            $where[] = 'start_utc < %s';
+            $params[] = gmdate(Calentra_Availability::UTC_FORMAT, (new DateTimeImmutable($f['to'], $tz))->modify('+1 day')->getTimestamp());
+        }
+
+        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- sorting a read-only list.
+        $orderby_key = isset($_GET['orderby']) ? sanitize_key(wp_unslash($_GET['orderby'])) : 'start';
+        $order = isset($_GET['order']) && strtolower(sanitize_key(wp_unslash($_GET['order']))) === 'desc' ? 'DESC' : 'ASC';
+        // phpcs:enable WordPress.Security.NonceVerification.Recommended
+        $orderby = self::SORTABLE[$orderby_key] ?? 'start_utc';
+
+        $page = $this->get_pagenum();
+        $where_sql = implode(' AND ', $where);
+        $table = Calentra_Install::bookings_table();
+
+        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where_sql is built only from the literal fragments above; $orderby and $order come from allow-lists.
+        // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- the sniff cannot see into $where_sql or count array_merge(); at runtime the count matches, because each $where fragment adds exactly one placeholder and one entry to $params.
+        $total = (int) $wpdb->get_var($wpdb->prepare(
+            "SELECT COUNT(*) FROM %i WHERE $where_sql",
+            array_merge([$table], $params)
+        ));
+        $this->items = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM %i WHERE $where_sql ORDER BY $orderby $order, id ASC LIMIT %d OFFSET %d",
+            array_merge([$table], $params, [self::PER_PAGE, ($page - 1) * self::PER_PAGE])
+        ));
+        // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+        $this->_column_headers = [$this->get_columns(), [], $this->get_sortable_columns(), $this->get_primary_column_name()];
+        $this->set_pagination_args([
+            'total_items' => $total,
+            'per_page'    => self::PER_PAGE,
+            'total_pages' => (int) ceil($total / self::PER_PAGE),
+        ]);
+    }
+
+    /**
+     * Products that have at least one booking, for the filter dropdown.
+     */
+    private static function booked_products() {
+        global $wpdb;
+        $ids = $wpdb->get_col($wpdb->prepare('SELECT DISTINCT product_id FROM %i', Calentra_Install::bookings_table()));
+        $out = [];
+        foreach ($ids as $id) {
+            $out[(int) $id] = get_the_title($id) ?: sprintf(
+                /* translators: %d: product ID */
+                __('Product #%d (deleted)', 'navarro-booking-for-woocommerce'),
+                $id
+            );
+        }
+        asort($out);
+        return $out;
+    }
+
+    protected function extra_tablenav($which) {
+        if ($which !== 'top') {
+            return;
+        }
+        $f = self::filters();
+        ?>
+        <div class="alignleft actions">
+            <label class="screen-reader-text" for="calentra-filter-product"><?php esc_html_e('Filter by product', 'navarro-booking-for-woocommerce'); ?></label>
+            <select name="product" id="calentra-filter-product">
+                <option value="0"><?php esc_html_e('All products', 'navarro-booking-for-woocommerce'); ?></option>
+                <?php foreach (self::booked_products() as $id => $title) : ?>
+                    <option value="<?php echo (int) $id; ?>" <?php selected($f['product'], $id); ?>><?php echo esc_html($title); ?></option>
+                <?php endforeach; ?>
+            </select>
+
+            <label class="screen-reader-text" for="calentra-filter-status"><?php esc_html_e('Filter by status', 'navarro-booking-for-woocommerce'); ?></label>
+            <select name="booking_status" id="calentra-filter-status">
+                <?php foreach (self::status_labels() as $value => $label) : ?>
+                    <option value="<?php echo esc_attr($value); ?>" <?php selected($f['status'], $value); ?>><?php echo esc_html($label); ?></option>
+                <?php endforeach; ?>
+            </select>
+
+            <label for="calentra-filter-from"><?php esc_html_e('From', 'navarro-booking-for-woocommerce'); ?></label>
+            <input type="date" name="from" id="calentra-filter-from" value="<?php echo esc_attr($f['from']); ?>">
+            <label for="calentra-filter-to"><?php esc_html_e('to', 'navarro-booking-for-woocommerce'); ?></label>
+            <input type="date" name="to" id="calentra-filter-to" value="<?php echo esc_attr($f['to']); ?>">
+
+            <?php submit_button(__('Filter', 'navarro-booking-for-woocommerce'), '', 'filter_action', false); ?>
+        </div>
+        <?php
+    }
+
+    private static function status_labels() {
+        return [
+            'active'    => __('Active (confirmed and live holds)', 'navarro-booking-for-woocommerce'),
+            'confirmed' => __('Confirmed', 'navarro-booking-for-woocommerce'),
+            'hold'      => __('In a cart (hold)', 'navarro-booking-for-woocommerce'),
+            'cancelled' => __('Cancelled', 'navarro-booking-for-woocommerce'),
+            'expired'   => __('Expired hold', 'navarro-booking-for-woocommerce'),
+            'all'       => __('All statuses', 'navarro-booking-for-woocommerce'),
+        ];
+    }
+
+    protected function column_default($item, $column_name) {
+        $tz = wp_timezone();
+        switch ($column_name) {
+            case 'id':
+                return '#' . (int) $item->id;
+
+            case 'product':
+                $link = get_edit_post_link($item->product_id);
+                $title = get_the_title($item->product_id) ?: '#' . (int) $item->product_id;
+                return $link ? '<a href="' . esc_url($link) . '">' . esc_html($title) . '</a>' : esc_html($title);
+
+            case 'resource':
+                if (!(int) $item->resource_id) {
+                    return '—';
+                }
+                /**
+                 * Filters how a resource is named in the bookings list. Core has no
+                 * resource names of its own, so it shows the ID.
+                 *
+                 * @param string $label       Default "#<id>".
+                 * @param int    $resource_id Resource ID.
+                 * @param object $booking     The booking row.
+                 */
+                return esc_html(apply_filters('calentra_resource_label', '#' . (int) $item->resource_id, (int) $item->resource_id, $item));
+
+            case 'start':
+            case 'end':
+                $ts = strtotime(($column_name === 'start' ? $item->start_utc : $item->end_utc) . ' UTC');
+                $format = $item->booking_type === 'multi-day'
+                    ? get_option('date_format')
+                    : get_option('date_format') . ' ' . get_option('time_format') . ' T';
+                return esc_html(wp_date($format, $ts, $tz));
+
+            case 'seats':
+                return (int) $item->seats;
+
+            case 'status':
+                $labels = self::status_labels();
+                if ($item->status === 'hold') {
+                    $expires = strtotime($item->hold_expires . ' UTC');
+                    if ($expires < time()) {
+                        return esc_html__('Hold (lapsed)', 'navarro-booking-for-woocommerce');
+                    }
+                    return esc_html(sprintf(
+                        /* translators: %s: time the hold expires */
+                        __('In a cart until %s', 'navarro-booking-for-woocommerce'),
+                        wp_date(get_option('time_format') . ' T', $expires, $tz)
+                    ));
+                }
+                return esc_html($labels[$item->status] ?? $item->status);
+
+            case 'order':
+                if (!$item->order_id) {
+                    return '—';
+                }
+                $order = wc_get_order($item->order_id);
+                if (!$order) {
+                    /* translators: %d: order number */
+                    return esc_html(sprintf(__('#%d (deleted)', 'navarro-booking-for-woocommerce'), $item->order_id));
+                }
+                return '<a href="' . esc_url($order->get_edit_order_url()) . '">#' . esc_html($order->get_order_number()) . '</a> '
+                    . '<span class="description">' . esc_html(wc_get_order_status_name($order->get_status())) . '</span>';
+        }
+        return '';
+    }
+
+    /**
+     * Renders the whole screen.
+     */
+    public static function render_page() {
+        if (!current_user_can('manage_woocommerce')) {
+            return;
+        }
+        $table = new self();
+        $table->prepare_items();
+        ?>
+        <div class="wrap">
+            <h1 class="wp-heading-inline"><?php esc_html_e('Bookings', 'navarro-booking-for-woocommerce'); ?></h1>
+            <?php Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_BOOKINGS_ACTIONS); ?>
+            <p class="description"><?php
+                /* translators: %s: timezone name */
+                printf(esc_html__('Times are shown in the site timezone (%s).', 'navarro-booking-for-woocommerce'), esc_html(wp_timezone_string()));
+            ?></p>
+            <form method="get">
+                <input type="hidden" name="page" value="calentra-bookings">
+                <?php $table->display(); ?>
+            </form>
+        </div>
+        <?php
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-bookings.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-bookings.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-bookings.php	(revision 3750436)
@@ -0,0 +1,779 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/*
+ * Every query in this file runs against Navarro Booking's own tables, which WordPress has no
+ * API for, so the direct-query and caching sniffs cannot be satisfied here. They are
+ * switched off for the file rather than line by line, on one invariant:
+ *
+ *   table names come only from Calentra_Install::bookings_table() and
+ *   inventory_table(), each of which is $wpdb->prefix plus a literal, and every value
+ *   is a %d or %s placeholder passed through $wpdb->prepare().
+ *
+ * Availability is deliberately uncached: a stale read here would sell a seat twice,
+ * which is the failure this class exists to prevent. The REST layer caches instead,
+ * where a short TTL is safe.
+ *
+ * If you add a query that interpolates anything other than those two table names,
+ * re-enable the sniffs around it rather than widening this block.
+ */
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+/**
+ * Booking rows and slot inventory.
+ *
+ * Integrity rests on one statement: a conditional UPDATE on the inventory counter,
+ *
+ *     UPDATE inventory SET seats_used = seats_used + n
+ *      WHERE <unit> AND seats_used + n <= capacity
+ *
+ * which InnoDB executes atomically under a row lock. If it affects no row, the unit
+ * filled up between the availability read and this write, and the claim is refused.
+ * No transaction or cron job is involved.
+ *
+ * A "unit" is what capacity is counted against: the slot start for a timeslot booking,
+ * and each night (local midnight, in UTC) for a multi-day booking.
+ *
+ * A booking on a resource (resource_id > 0) is counted twice: on the product's own row,
+ * as above, and on the resource's shared rows (product_id = 0), which every product
+ * using that resource draws from. The resource's capacity is the pool; a product's own
+ * capacity on it only caps what that product may take. Shared rows are keyed on
+ * five-minute granules rather than slot starts, because products sharing a resource can
+ * have different slot grids: a 18:15-19:30 class overlaps an 18:00-19:00 one without
+ * starting at the same instant. See resource_units_for() and claim().
+ *
+ * Every status change that gives seats back is itself a conditional UPDATE on the
+ * booking row (WHERE status = <expected>), so exactly one caller wins the right to
+ * decrement the counter, even if two requests release the same booking at once.
+ */
+class Calentra_Bookings {
+
+    const STATUS_HOLD = 'hold';
+    const STATUS_CONFIRMED = 'confirmed';
+    const STATUS_CANCELLED = 'cancelled';
+    const STATUS_EXPIRED = 'expired';
+
+    /** Upper bound on resources tried for one claim; see claim_candidates(). */
+    const MAX_CLAIM_CANDIDATES = 50;
+
+    /**
+     * Length of a resource-wide unit for a timeslot booking, in seconds.
+     *
+     * Deliberately not filterable. Every shared inventory row already written is keyed
+     * on this granule, and seats are given back by recomputing the granules from the
+     * booking; changing it on a live site would give back seats on rows that were never
+     * claimed and leave the real ones counted forever. Changing it needs a DB_VERSION
+     * bump and a rebuild of the inventory, not a filter.
+     */
+    const RESOURCE_GRANULE = 300;
+
+    /** Upper bound on resource-wide units for one booking (about 33 hours of granules). */
+    const MAX_RESOURCE_UNITS = 400;
+
+    public static function hold_seconds($product_id) {
+        /**
+         * Filters how long an add-to-cart hold reserves a slot, in seconds.
+         *
+         * @param int $seconds    Default from the settings page (60 minutes).
+         * @param int $product_id Product ID.
+         */
+        $minutes = (int) get_option('calentra_hold_minutes', 60) ?: 60;
+        return max(60, (int) apply_filters('calentra_hold_duration', $minutes * MINUTE_IN_SECONDS, (int) $product_id));
+    }
+
+    public static function get($booking_id) {
+        global $wpdb;
+        return $wpdb->get_row($wpdb->prepare(
+            'SELECT * FROM ' . Calentra_Install::bookings_table() . ' WHERE id = %d',
+            $booking_id
+        ));
+    }
+
+    public static function for_order($order_id) {
+        global $wpdb;
+        return $wpdb->get_results($wpdb->prepare(
+            'SELECT * FROM ' . Calentra_Install::bookings_table() . ' WHERE order_id = %d',
+            $order_id
+        ));
+    }
+
+    /**
+     * A live hold this cart session already has on the same slot, if any.
+     */
+    public static function find_session_hold($session_key, $product_id, $resource_id, $start_utc) {
+        global $wpdb;
+        if ($session_key === '') {
+            return null;
+        }
+        return $wpdb->get_row($wpdb->prepare(
+            "SELECT * FROM %i WHERE session_key = %s AND product_id = %d AND resource_id = %d
+                AND start_utc = %s AND status = 'hold' AND hold_expires >= %s ORDER BY id DESC LIMIT 1",
+            Calentra_Install::bookings_table(), $session_key, $product_id, $resource_id, $start_utc, gmdate(Calentra_Availability::UTC_FORMAT)
+        ));
+    }
+
+    /**
+     * Capacity units a booking occupies, as UTC "Y-m-d H:i:s" strings.
+     *
+     * @param object|array $booking Row with booking_type, start_utc, end_utc, site_timezone.
+     */
+    public static function units_for($booking) {
+        $booking = (object) $booking;
+        if (($booking->booking_type ?? 'timeslot') !== 'multi-day') {
+            return [$booking->start_utc];
+        }
+        try {
+            $tz = new DateTimeZone($booking->site_timezone ?: 'UTC');
+        } catch (Exception $e) {
+            $tz = wp_timezone();
+        }
+        $units = [];
+        $night = (new DateTimeImmutable($booking->start_utc . ' UTC'))->setTimezone($tz);
+        $end = strtotime($booking->end_utc . ' UTC');
+        while ($night->getTimestamp() < $end && count($units) < 400) {
+            $units[] = gmdate(Calentra_Availability::UTC_FORMAT, $night->getTimestamp());
+            $night = $night->modify('+1 day')->setTime(0, 0);
+        }
+        return $units;
+    }
+
+    /**
+     * Resource-wide units a booking occupies on its resource's shared rows, as UTC
+     * "Y-m-d H:i:s" strings in ascending order.
+     *
+     * Timeslot: every RESOURCE_GRANULE granule that [start, end) touches, rounded
+     * outwards, so any two bookings that overlap in time share at least one granule
+     * whatever their slot grids. A boundary off the five-minute grid can make two
+     * bookings that only touch share a granule; that refuses a booking, never oversells.
+     *
+     * Multi-day: the same nights as units_for(). A resource is never shared between a
+     * multi-day and a timeslot product (the extension attaching resources must refuse
+     * it, as Navarro Booking Pro does), so nights and granules never meet on one row.
+     *
+     * @param object|array $booking Row with booking_type, start_utc, end_utc, site_timezone.
+     * @return string[]|null Null if the booking spans more than MAX_RESOURCE_UNITS.
+     */
+    public static function resource_units_for($booking) {
+        $booking = (object) $booking;
+        if (($booking->booking_type ?? 'timeslot') === 'multi-day') {
+            return self::units_for($booking);
+        }
+        $granule = self::RESOURCE_GRANULE;
+        $start = (int) strtotime($booking->start_utc . ' UTC');
+        $end = (int) strtotime($booking->end_utc . ' UTC');
+        $first = intdiv($start, $granule) * $granule;
+        $last = max($first + $granule, intdiv($end + $granule - 1, $granule) * $granule);
+        if (($last - $first) / $granule > self::MAX_RESOURCE_UNITS) {
+            return null;
+        }
+        $units = [];
+        for ($t = $first; $t < $last; $t += $granule) {
+            $units[] = gmdate(Calentra_Availability::UTC_FORMAT, $t);
+        }
+        return $units;
+    }
+
+    /**
+     * Atomically takes $seats for a booking on every unit it occupies, or on none.
+     *
+     * On a resource, the resource's shared granules are taken first, all of them in
+     * ascending time order, and the product's own units after. Both orders matter:
+     *
+     *  - Resource first: when the resource is full, the claim fails on its first step
+     *    with nothing taken and nothing to roll back. The other order would make that,
+     *    the common refusal, the one that rolls back, and while a rollback is in flight
+     *    a concurrent claim can be refused a seat that exists.
+     *  - Ascending time: two overlapping claims meet first on their earliest shared
+     *    granule, and the loser fails there, before taking anything the winner needs
+     *    later. Claims for the same interval (every racer for one slot) are decided on
+     *    the first granule with no rollback at all.
+     *
+     * Three or more claims with different, partly overlapping intervals can still make
+     * one fail while another is rolling back (a refusal of a seat that turns out to be
+     * free a moment later). That needs differing intervals racing, refuses rather than
+     * oversells, and is accepted.
+     *
+     * @param object|array $booking Row with booking_type, start_utc, end_utc, site_timezone.
+     * @return bool False if any unit is full, or the booking is too long for the resource.
+     */
+    private static function claim($product_id, $resource_id, $booking, $seats) {
+        $steps = self::steps_for($product_id, $resource_id, $booking);
+        return $steps !== null && self::claim_steps($steps, $resource_id, $seats);
+    }
+
+    /**
+     * The rows a booking occupies, in claim order: [scope, unit] pairs, the resource's
+     * shared granules first (ascending), then the product's own units. Null if the
+     * booking is too long for its resource.
+     *
+     * @return array<int, array{0: int, 1: string}>|null
+     */
+    private static function steps_for($product_id, $resource_id, $booking) {
+        $steps = [];
+        if ((int) $resource_id > 0) {
+            $shared = self::resource_units_for($booking);
+            if ($shared === null) {
+                return null;
+            }
+            foreach ($shared as $unit) {
+                $steps[] = [0, $unit];
+            }
+        }
+        foreach (self::units_for($booking) as $unit) {
+            $steps[] = [(int) $product_id, $unit];
+        }
+        return $steps;
+    }
+
+    /**
+     * Takes $seats on each [scope, unit] row in order, or on none.
+     */
+    private static function claim_steps(array $steps, $resource_id, $seats) {
+        $resource_id = (int) $resource_id;
+        $taken = [];
+        foreach ($steps as $step) {
+            [$scope, $unit] = $step;
+            if (!self::claim_row($scope, $resource_id, $unit, $seats)) {
+                // All or nothing: give back every row this claim took, in either scope.
+                foreach ($taken as $done) {
+                    self::decrement($done[0], $resource_id, $done[1], $seats);
+                }
+                return false;
+            }
+            $taken[] = $step;
+        }
+        return true;
+    }
+
+    /**
+     * Takes $seats on one inventory row. $scope is the product, or 0 for the resource's
+     * shared row.
+     */
+    private static function claim_row($scope, $resource_id, $unit, $seats) {
+        global $wpdb;
+        $table = Calentra_Install::inventory_table();
+        // For the shared row this asks calentra_slot_capacity with product 0: the
+        // resource's own capacity, across every product.
+        $capacity = Calentra_Availability::capacity($scope, $resource_id, $unit);
+        // The inventory row is created lazily on first claim.
+        $wpdb->query($wpdb->prepare(
+            "INSERT IGNORE INTO $table (product_id, resource_id, start_utc, seats_total, seats_used) VALUES (%d, %d, %s, %d, 0)",
+            $scope, $resource_id, $unit, $capacity
+        ));
+        $ok = self::conditional_increment($scope, $resource_id, $unit, $seats, $capacity);
+        if (!$ok && self::reclaim_expired_holds($scope, $resource_id, $unit)) {
+            $ok = self::conditional_increment($scope, $resource_id, $unit, $seats, $capacity);
+        }
+        return $ok;
+    }
+
+    /**
+     * Gives back $seats a booking holds, on every row claim() took them from. The only
+     * way seats are returned for a booking: every release path goes through here, so
+     * none can return the product's seats and forget the resource's.
+     *
+     * The product's rows go back first, the reverse of claim(): a concurrent claim then
+     * finds the resource still full and is refused at once, instead of taking a shared
+     * seat and rolling it back.
+     *
+     * @param object|array $booking Row with product_id, resource_id and the times.
+     */
+    private static function give_back($booking, $seats) {
+        $booking = (object) $booking;
+        $product_id = (int) $booking->product_id;
+        $resource_id = (int) $booking->resource_id;
+        foreach (self::units_for($booking) as $unit) {
+            self::decrement($product_id, $resource_id, $unit, $seats);
+        }
+        if ($resource_id > 0) {
+            foreach ((array) self::resource_units_for($booking) as $unit) {
+                self::decrement(0, $resource_id, $unit, $seats);
+            }
+        }
+    }
+
+    private static function conditional_increment($product_id, $resource_id, $unit, $seats, $capacity) {
+        global $wpdb;
+        $table = Calentra_Install::inventory_table();
+        $wpdb->query($wpdb->prepare(
+            "UPDATE $table SET seats_used = seats_used + %d, seats_total = %d
+              WHERE product_id = %d AND resource_id = %d AND start_utc = %s
+                AND seats_used + %d <= %d",
+            $seats, $capacity, $product_id, $resource_id, $unit, $seats, $capacity
+        ));
+        return $wpdb->rows_affected === 1;
+    }
+
+    private static function decrement($product_id, $resource_id, $unit, $seats) {
+        global $wpdb;
+        $table = Calentra_Install::inventory_table();
+        $wpdb->query($wpdb->prepare(
+            "UPDATE $table SET seats_used = IF(seats_used >= %d, seats_used - %d, 0)
+              WHERE product_id = %d AND resource_id = %d AND start_utc = %s",
+            $seats, $seats, $product_id, $resource_id, $unit
+        ));
+    }
+
+    /**
+     * Returns the seats of holds that have expired on a unit to the counter. Each hold is
+     * retired with a conditional UPDATE, so only one request gives its seats back.
+     *
+     * For a resource's shared row ($scope 0) that means lapsed holds on the resource from
+     * every product, not just the one claiming: otherwise a lapsed hold on one product
+     * would keep the resource full for all the others until something else retired it.
+     * Retiring goes through give_back(), so each hold leaves both scopes.
+     *
+     * The candidate query may match more lapsed holds than strictly cover the unit (a
+     * granule test on a night, say). Retiring any lapsed hold is always correct.
+     *
+     * @return bool Whether any seats were returned.
+     */
+    private static function reclaim_expired_holds($scope, $resource_id, $unit) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        if ((int) $scope === 0 && (int) $resource_id > 0) {
+            // Holds overlapping the granule [unit, unit + RESOURCE_GRANULE), from any product.
+            $candidates = $wpdb->get_results($wpdb->prepare(
+                "SELECT * FROM $table
+                  WHERE resource_id = %d AND status = 'hold' AND hold_expires < %s
+                    AND start_utc < %s AND end_utc > %s",
+                $resource_id, $now, gmdate(Calentra_Availability::UTC_FORMAT, strtotime($unit . ' UTC') + self::RESOURCE_GRANULE), $unit
+            ));
+        } else {
+            $candidates = $wpdb->get_results($wpdb->prepare(
+                "SELECT * FROM $table
+                  WHERE product_id = %d AND resource_id = %d AND status = 'hold' AND hold_expires < %s
+                    AND start_utc <= %s AND end_utc > %s",
+                $scope, $resource_id, $now, $unit, $unit
+            ));
+        }
+        $freed = false;
+        foreach ((array) $candidates as $hold) {
+            if (self::transition($hold, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired')) {
+                $freed = true;
+            }
+        }
+        return $freed;
+    }
+
+    /**
+     * Conditionally moves a booking out of a seat-holding status and returns its seats.
+     */
+    private static function transition($booking, array $from, $to, $reason) {
+        global $wpdb;
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $from = array_pad(array_values($from), 2, $from[0]); // IN (%s, %s); a repeated status is harmless.
+        if ($to === self::STATUS_EXPIRED) {
+            // Expiring must not race a just-extended or just-confirmed hold.
+            $wpdb->query($wpdb->prepare(
+                'UPDATE %i SET status = %s, updated_at = %s WHERE id = %d AND status IN (%s, %s) AND hold_expires < %s',
+                Calentra_Install::bookings_table(), $to, $now, $booking->id, $from[0], $from[1], $now
+            ));
+        } else {
+            $wpdb->query($wpdb->prepare(
+                'UPDATE %i SET status = %s, updated_at = %s WHERE id = %d AND status IN (%s, %s)',
+                Calentra_Install::bookings_table(), $to, $now, $booking->id, $from[0], $from[1]
+            ));
+        }
+        if ($wpdb->rows_affected !== 1) {
+            return false;
+        }
+        self::give_back($booking, (int) $booking->seats);
+        Calentra_Availability::bump_cache_version($booking->product_id);
+
+        /**
+         * Fires when a booking gives its seats back.
+         *
+         * @param int    $booking_id Booking ID.
+         * @param string $reason     expired | cancelled | released.
+         * @param object $booking    The row before the change.
+         */
+        do_action('calentra_booking_released', (int) $booking->id, $reason, $booking);
+        return true;
+    }
+
+    /**
+     * Resources to try, in order, for a new claim. By default only the one requested.
+     *
+     * @return int[]
+     */
+    private static function claim_candidates($product_id, array $booking) {
+        /**
+         * Filters the resources a new booking may be claimed on, in the order to try
+         * them. Core claims each atomically in turn and the first with room wins; the
+         * booking records that resource. Use it to pool resources (any free member of
+         * staff, any free room) without reading availability first.
+         *
+         * Applies to every new claim: cart holds, re-claims of lapsed holds at checkout,
+         * restored orders and bookings created in the admin. Resizing or moving an
+         * existing booking stays on its resource.
+         *
+         * @param int[] $resources  Default: the requested resource only.
+         * @param int   $product_id Product ID.
+         * @param array $booking    The validated booking, with the requested resource_id.
+         *                          When a lapsed cart hold is re-claimed it also carries
+         *                          previous_resource_id, the resource it held before.
+         */
+        $candidates = (array) apply_filters('calentra_claim_resources', [(int) $booking['resource_id']], (int) $product_id, $booking);
+        // Only whole numbers 0 or above. Junk is dropped, not cast: (int) 'abc' is 0,
+        // and a negative ID stored in the unsigned column becomes 0 under a non-strict
+        // sql_mode; either would quietly book the product itself.
+        $candidates = array_values(array_unique(array_map('intval', array_filter($candidates, function ($id) {
+            return (is_int($id) && $id >= 0) || (is_string($id) && ctype_digit($id));
+        }))));
+        // Each candidate costs one conditional UPDATE per unit under contention.
+        return array_slice($candidates, 0, self::MAX_CLAIM_CANDIDATES);
+    }
+
+    /**
+     * Places a hold for a validated booking (see Calentra_Availability::validate()).
+     *
+     * @return int|WP_Error Booking ID.
+     */
+    public static function create_hold($product_id, array $booking, $session_key) {
+        return self::create($product_id, $booking, [
+            'status'       => self::STATUS_HOLD,
+            'hold_expires' => gmdate(Calentra_Availability::UTC_FORMAT, time() + self::hold_seconds($product_id)),
+            'session_key'  => substr((string) $session_key, 0, 64),
+        ]);
+    }
+
+    /**
+     * Creates a confirmed booking directly, for orders made without a cart (admin).
+     *
+     * @return int|WP_Error
+     */
+    public static function create_confirmed($product_id, array $booking, $order_id, $item_id) {
+        return self::create($product_id, $booking, [
+            'status'        => self::STATUS_CONFIRMED,
+            'order_id'      => (int) $order_id,
+            'order_item_id' => (int) $item_id,
+        ]);
+    }
+
+    private static function create($product_id, array $booking, array $fields) {
+        global $wpdb;
+        $row = (object) [
+            'product_id'    => (int) $product_id,
+            'resource_id'   => (int) $booking['resource_id'],
+            'booking_type'  => $booking['type'],
+            'start_utc'     => $booking['start_utc'],
+            'end_utc'       => $booking['end_utc'],
+            'site_timezone' => $booking['timezone'],
+            'seats'         => (int) $booking['seats'],
+        ];
+        $candidates = self::claim_candidates($row->product_id, $booking);
+        if (!$candidates) {
+            // Nothing was offered to claim, which is not a lost race: say so, and don't
+            // fire calentra_claim_failed.
+            return new WP_Error('calentra_no_resource', __('Choose a resource for this booking.', 'navarro-booking-for-woocommerce'));
+        }
+        if (max($candidates) > 0 && self::resource_units_for($row) === null) {
+            return self::too_long_error();
+        }
+
+        $winner = null;
+        foreach ($candidates as $resource_id) {
+            // Each attempt is the same atomic conditional UPDATE as a single claim. A
+            // full resource is skipped because its UPDATE affects no row, never because
+            // availability was read first.
+            if (self::claim($row->product_id, $resource_id, $row, $row->seats)) {
+                $winner = $resource_id;
+                break;
+            }
+        }
+        if ($winner === null) {
+            /**
+             * Fires when a claim loses the race for a slot: every candidate resource
+             * was full.
+             *
+             * @param int   $product_id Product ID.
+             * @param array $booking    The booking that could not be claimed.
+             */
+            do_action('calentra_claim_failed', (int) $product_id, $booking);
+            return new WP_Error('calentra_slot_taken', __('Sorry, someone else has just booked that slot. Please choose another.', 'navarro-booking-for-woocommerce'));
+        }
+        // From here on the row, the insert and any rollback must name the resource that
+        // was actually claimed, not the one that was asked for.
+        $row->resource_id = $winner;
+        $booking['resource_id'] = $winner;
+
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $data = array_merge((array) $row, $fields, ['created_at' => $now, 'updated_at' => $now]);
+        if (!$wpdb->insert(Calentra_Install::bookings_table(), $data)) {
+            self::give_back($row, $row->seats);
+            return new WP_Error('calentra_db', __('The booking could not be saved. Please try again.', 'navarro-booking-for-woocommerce'));
+        }
+        $id = (int) $wpdb->insert_id;
+        Calentra_Availability::bump_cache_version($product_id);
+
+        if ($fields['status'] === self::STATUS_HOLD) {
+            /**
+             * Fires after a cart hold is placed.
+             *
+             * @param int   $booking_id Booking ID.
+             * @param array $booking    The validated booking.
+             */
+            do_action('calentra_hold_created', $id, $booking);
+        } else {
+            /** This action is documented in includes/class-calentra-bookings.php */
+            do_action('calentra_booking_confirmed', $id, (int) $fields['order_id']);
+        }
+        return $id;
+    }
+
+    /**
+     * Changes the seats a live hold covers (the customer changed the cart quantity).
+     *
+     * @return bool|WP_Error
+     */
+    public static function resize_hold($booking_id, $seats) {
+        global $wpdb;
+        $booking = self::get($booking_id);
+        $seats = max(1, (int) $seats);
+        if (!$booking || $booking->status !== self::STATUS_HOLD) {
+            return new WP_Error('calentra_no_hold', __('Your reservation has expired.', 'navarro-booking-for-woocommerce'));
+        }
+        $delta = $seats - (int) $booking->seats;
+        if ($delta === 0) {
+            return true;
+        }
+        if ($delta > 0 && !self::claim((int) $booking->product_id, (int) $booking->resource_id, $booking, $delta)) {
+            return new WP_Error('calentra_slot_taken', __('Sorry, there are not enough places left in that slot.', 'navarro-booking-for-woocommerce'));
+        }
+        $wpdb->query($wpdb->prepare(
+            'UPDATE ' . Calentra_Install::bookings_table() . " SET seats = %d, updated_at = %s WHERE id = %d AND status = 'hold'",
+            $seats, gmdate(Calentra_Availability::UTC_FORMAT), $booking_id
+        ));
+        if ($wpdb->rows_affected !== 1) {
+            // The hold was retired underneath us; hand back what we just took.
+            if ($delta > 0) {
+                self::give_back($booking, $delta);
+            }
+            return new WP_Error('calentra_no_hold', __('Your reservation has expired.', 'navarro-booking-for-woocommerce'));
+        }
+        if ($delta < 0) {
+            self::give_back($booking, -$delta);
+        }
+        Calentra_Availability::bump_cache_version($booking->product_id);
+        return true;
+    }
+
+    /**
+     * Pushes a live hold's expiry forward (the customer is still active in the cart).
+     *
+     * @return bool False if the hold had already expired or gone.
+     */
+    public static function extend_hold($booking_id) {
+        global $wpdb;
+        $booking = self::get($booking_id);
+        if (!$booking) {
+            return false;
+        }
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $wpdb->query($wpdb->prepare(
+            "UPDATE %i SET hold_expires = %s, updated_at = %s
+              WHERE id = %d AND status = 'hold' AND hold_expires >= %s",
+            Calentra_Install::bookings_table(), gmdate(Calentra_Availability::UTC_FORMAT, time() + self::hold_seconds($booking->product_id)), $now, $booking_id, $now
+        ));
+        if ($wpdb->rows_affected === 1) {
+            return true;
+        }
+        // MySQL reports changed rows, not matched rows: extending twice in the same
+        // second writes identical values and reports 0. Check the hold directly.
+        return (bool) $wpdb->get_var($wpdb->prepare(
+            'SELECT 1 FROM ' . Calentra_Install::bookings_table() . " WHERE id = %d AND status = 'hold' AND hold_expires >= %s",
+            $booking_id, $now
+        ));
+    }
+
+    /**
+     * Converts a hold into a confirmed booking for an order. Idempotent: confirming a
+     * booking already confirmed for the same order succeeds.
+     *
+     * If the hold expired, its seats are returned and the slot is claimed afresh; the
+     * order only fails if someone else has taken the slot in the meantime.
+     *
+     * @return int|WP_Error Booking ID (may differ from the hold's if it was re-claimed).
+     */
+    public static function confirm($booking_id, $order_id, $item_id, $seats) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $booking = self::get($booking_id);
+        if (!$booking) {
+            return new WP_Error('calentra_no_hold', __('Your reservation could not be found. Please choose your booking again.', 'navarro-booking-for-woocommerce'));
+        }
+        if ($booking->status === self::STATUS_CONFIRMED && (int) $booking->order_id === (int) $order_id) {
+            return (int) $booking->id;
+        }
+
+        if ($booking->status === self::STATUS_HOLD && (int) $booking->seats !== (int) $seats) {
+            $resized = self::resize_hold($booking_id, $seats);
+            if (is_wp_error($resized)) {
+                return $resized;
+            }
+            $booking = self::get($booking_id);
+        }
+
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $wpdb->query($wpdb->prepare(
+            "UPDATE $table SET status = 'confirmed', order_id = %d, order_item_id = %d, hold_expires = NULL, updated_at = %s
+              WHERE id = %d AND status = 'hold' AND hold_expires >= %s",
+            $order_id, $item_id, $now, $booking_id, $now
+        ));
+        if ($wpdb->rows_affected === 1) {
+            Calentra_Availability::bump_cache_version($booking->product_id);
+            /**
+             * Fires when a booking is confirmed against an order.
+             *
+             * @param int $booking_id Booking ID.
+             * @param int $order_id   Order ID.
+             */
+            do_action('calentra_booking_confirmed', (int) $booking_id, (int) $order_id);
+            return (int) $booking_id;
+        }
+
+        // The hold lapsed. Retire it (returning its seats if nobody has yet), then try
+        // to claim the same slot again.
+        if ($booking->status === self::STATUS_HOLD) {
+            self::transition($booking, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired');
+        }
+        $tz = $booking->site_timezone;
+        $request = [
+            'type'        => $booking->booking_type,
+            'start_utc'   => $booking->start_utc,
+            'end_utc'     => $booking->end_utc,
+            'timezone'    => $tz,
+            'seats'       => (int) $seats,
+            'resource_id' => (int) $booking->resource_id,
+        ];
+        $new_id = self::create_confirmed((int) $booking->product_id, $request, $order_id, $item_id);
+        if (is_wp_error($new_id)) {
+            return new WP_Error('calentra_slot_taken', __('Sorry, your reservation expired and the slot has since been booked by someone else. You have not been charged. Please choose another time.', 'navarro-booking-for-woocommerce'));
+        }
+        return $new_id;
+    }
+
+    /**
+     * Gives back the seats of a hold or confirmed booking.
+     */
+    public static function release($booking_id, $reason = 'released') {
+        $booking = self::get($booking_id);
+        if (!$booking) {
+            return false;
+        }
+        return self::transition($booking, [self::STATUS_HOLD, self::STATUS_CONFIRMED], self::STATUS_CANCELLED, $reason);
+    }
+
+    /**
+     * Releases an expired hold if it is still counted. Safe to call at any time.
+     */
+    public static function expire_if_lapsed($booking_id) {
+        $booking = self::get($booking_id);
+        if ($booking && $booking->status === self::STATUS_HOLD && strtotime($booking->hold_expires . ' UTC') < time()) {
+            return self::transition($booking, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired');
+        }
+        return false;
+    }
+
+    /**
+     * Moves a confirmed booking to a new time (admin edit). Claims the new units first,
+     * so a failed move leaves the original booking untouched.
+     *
+     * @return bool|WP_Error
+     */
+    public static function move($booking_id, array $booking) {
+        global $wpdb;
+        $old = self::get($booking_id);
+        if (!$old) {
+            return new WP_Error('calentra_missing', __('Booking not found.', 'navarro-booking-for-woocommerce'));
+        }
+        $new = clone $old;
+        $new->start_utc = $booking['start_utc'];
+        $new->end_utc = $booking['end_utc'];
+        $new->booking_type = $booking['type'];
+        $new->site_timezone = $booking['timezone'];
+
+        $new_steps = self::steps_for((int) $old->product_id, (int) $old->resource_id, $new);
+        if ($new_steps === null) {
+            return self::too_long_error();
+        }
+        // A booking that still holds seats only needs the rows it doesn't already hold,
+        // and gives back only the rows it leaves. Claiming the whole new interval would
+        // refuse any move that overlaps the old one (10:00 to 10:15 on a resource of one
+        // place) against the booking's own seats.
+        $active = in_array($old->status, [self::STATUS_HOLD, self::STATUS_CONFIRMED], true);
+        $old_steps = $active ? (array) self::steps_for((int) $old->product_id, (int) $old->resource_id, $old) : [];
+        $to_claim = self::steps_minus($new_steps, $old_steps);
+        $to_release = self::steps_minus($old_steps, $new_steps);
+        if (!self::claim_steps($to_claim, (int) $old->resource_id, (int) $old->seats)) {
+            return new WP_Error('calentra_slot_taken', __('That slot is already fully booked.', 'navarro-booking-for-woocommerce'));
+        }
+        $wpdb->update(Calentra_Install::bookings_table(), [
+            'start_utc'     => $new->start_utc,
+            'end_utc'       => $new->end_utc,
+            'booking_type'  => $new->booking_type,
+            'site_timezone' => $new->site_timezone,
+            'updated_at'    => gmdate(Calentra_Availability::UTC_FORMAT),
+        ], ['id' => $old->id]);
+        // Product rows first, as in give_back().
+        foreach (array_reverse($to_release) as $step) {
+            self::decrement($step[0], (int) $old->resource_id, $step[1], (int) $old->seats);
+        }
+        Calentra_Availability::bump_cache_version($old->product_id);
+        return true;
+    }
+
+    /**
+     * Steps in $a that are not in $b, keeping $a's order.
+     */
+    private static function steps_minus(array $a, array $b) {
+        $drop = [];
+        foreach ($b as $step) {
+            $drop[$step[0] . '|' . $step[1]] = true;
+        }
+        return array_values(array_filter($a, function ($step) use ($drop) {
+            return !isset($drop[$step[0] . '|' . $step[1]]);
+        }));
+    }
+
+    /**
+     * A booking on a resource longer than MAX_RESOURCE_UNITS granules. Refused outright:
+     * claiming part of its interval would leave the rest unprotected.
+     */
+    private static function too_long_error() {
+        return new WP_Error('calentra_too_long', __('This booking is too long to be made on a resource. Please choose a shorter booking.', 'navarro-booking-for-woocommerce'));
+    }
+
+    /**
+     * Optional tidy-up: retires lapsed holds and deletes very old non-active rows.
+     * Correctness never depends on this running; availability ignores expired holds and
+     * claims reclaim them on demand.
+     */
+    public static function sweep() {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $lapsed = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM $table WHERE status = 'hold' AND hold_expires < %s LIMIT 500",
+            $now
+        ));
+        foreach ((array) $lapsed as $hold) {
+            self::transition($hold, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired');
+        }
+        $wpdb->query($wpdb->prepare(
+            "DELETE FROM $table WHERE status = 'expired' AND order_id IS NULL AND updated_at < %s",
+            gmdate(Calentra_Availability::UTC_FORMAT, time() - 30 * DAY_IN_SECONDS)
+        ));
+        $wpdb->query($wpdb->prepare(
+            'DELETE FROM ' . Calentra_Install::inventory_table() . ' WHERE seats_used = 0 AND start_utc < %s',
+            gmdate(Calentra_Availability::UTC_FORMAT, time() - DAY_IN_SECONDS)
+        ));
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-cart.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-cart.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-cart.php	(revision 3750436)
@@ -0,0 +1,430 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Cart, checkout and order lifecycle.
+ *
+ *  add to cart  → validate against Calentra_Availability, then claim a hold
+ *  cart / checkout views → keep the hold alive, re-claim it if it lapsed
+ *  order placed → convert hold to confirmed atomically, or fail the order
+ *  cancelled / failed / refunded → release
+ *
+ * These hooks run in every request type (front end, admin, AJAX, Store API), because
+ * the block cart and checkout go through the REST API and order status changes
+ * usually happen in the admin.
+ */
+class Calentra_Cart {
+
+    const ITEM_KEY = 'calentra';
+
+    /** Booking validated in add_to_cart_validation, used by the hooks that follow it. */
+    private $pending = null;
+
+    public function init_hooks() {
+        add_filter('woocommerce_add_to_cart_validation', [$this, 'validate_add_to_cart'], 10, 5);
+        add_filter('woocommerce_add_cart_item_data', [$this, 'add_cart_item_data'], 10, 3);
+        add_action('woocommerce_add_to_cart', [$this, 'place_hold'], 10, 6);
+        add_action('woocommerce_remove_cart_item', [$this, 'release_removed_item'], 10, 2);
+        add_action('woocommerce_after_cart_item_quantity_update', [$this, 'resize_hold'], 10, 4);
+        add_action('woocommerce_check_cart_items', [$this, 'check_cart_holds']);
+        add_filter('woocommerce_get_item_data', [$this, 'display_cart_item'], 10, 2);
+        add_action('woocommerce_before_calculate_totals', [$this, 'apply_multiday_price'], 20);
+
+        add_action('woocommerce_checkout_create_order_line_item', [$this, 'add_order_item_meta'], 10, 4);
+        add_action('woocommerce_checkout_order_processed', [$this, 'confirm_classic_checkout'], 10, 3);
+        add_action('woocommerce_store_api_checkout_order_processed', [$this, 'confirm_store_api_checkout']);
+
+        foreach (['cancelled', 'failed', 'refunded'] as $status) {
+            add_action("woocommerce_order_status_{$status}", [$this, 'release_order']);
+        }
+        add_action('woocommerce_before_trash_order', [$this, 'release_order']);
+        add_action('woocommerce_before_delete_order', [$this, 'release_order']);
+        add_action('wp_trash_post', [$this, 'release_order_post']);
+        add_action('woocommerce_order_status_changed', [$this, 'reclaim_on_restore'], 10, 3);
+    }
+
+    private static function session_key() {
+        return (function_exists('WC') && WC()->session) ? (string) WC()->session->get_customer_id() : '';
+    }
+
+    /**
+     * Booking fields from an add-to-cart request. The classic product form posts them;
+     * WooCommerce does not put a nonce on add-to-cart, and these values are validated
+     * against availability before anything is stored.
+     */
+    private function request_fields() {
+        // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
+        $read = function ($key) {
+            return isset($_REQUEST[$key]) ? sanitize_text_field(wp_unslash($_REQUEST[$key])) : '';
+        };
+        // phpcs:enable
+        return [
+            'date'     => $read('calentra_booking_date'),
+            'time'     => $read('calentra_booking_time'),
+            'end_date' => $read('calentra_booking_end_date'),
+        ];
+    }
+
+    private static function resource_id($product_id, array $fields) {
+        /**
+         * Filters which resource a booking is made against. 0 means the product itself.
+         *
+         * @param int   $resource_id Default 0.
+         * @param int   $product_id  Product ID.
+         * @param array $fields      The requested date / time / end_date.
+         */
+        return (int) apply_filters('calentra_booking_resource_id', 0, (int) $product_id, $fields);
+    }
+
+    /** Quantity of the same booking already in the cart, which a merge would add to. */
+    private function quantity_in_cart($product_id, array $booking) {
+        $qty = 0;
+        if (!WC()->cart) {
+            return 0;
+        }
+        foreach (WC()->cart->get_cart() as $item) {
+            if ((int) $item['product_id'] === (int) $product_id && !empty($item[self::ITEM_KEY])
+                && $item[self::ITEM_KEY]['start_utc'] === $booking['start_utc']) {
+                $qty += (int) $item['quantity'];
+            }
+        }
+        return $qty;
+    }
+
+    public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id = 0, $variations = []) {
+        $this->pending = null;
+        $config = Calentra_Rules::product_config($product_id);
+        if (!$passed || !$config['enabled']) {
+            return $passed;
+        }
+
+        $fields = $this->request_fields();
+        $args = ['session_key' => self::session_key(), 'resource_id' => self::resource_id($product_id, $fields)];
+        $booking = Calentra_Availability::validate($product_id, $fields + ['seats' => $quantity], $args);
+        if (!is_wp_error($booking)) {
+            $in_cart = $this->quantity_in_cart($product_id, $booking);
+            if ($in_cart > 0) {
+                $booking = Calentra_Availability::validate($product_id, $fields + ['seats' => $quantity + $in_cart], $args);
+            }
+        }
+        if (is_wp_error($booking)) {
+            wc_add_notice($booking->get_error_message(), 'error');
+            return false;
+        }
+        $this->pending = $booking;
+        return true;
+    }
+
+    public function add_cart_item_data($cart_item_data, $product_id, $variation_id) {
+        if ($this->pending) {
+            $b = $this->pending;
+            // Only the booking identity goes in, so adding the same slot twice merges
+            // into one line rather than creating two.
+            $cart_item_data[self::ITEM_KEY] = [
+                'type'        => $b['type'],
+                'date'        => $b['date'],
+                'time'        => $b['time'],
+                'end_date'    => $b['end_date'],
+                'start_utc'   => $b['start_utc'],
+                'end_utc'     => $b['end_utc'],
+                'timezone'    => $b['timezone'],
+                'resource_id' => $b['resource_id'],
+            ];
+        }
+        return $cart_item_data;
+    }
+
+    public function place_hold($cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data) {
+        if (empty($cart_item_data[self::ITEM_KEY]) || !$this->pending) {
+            return;
+        }
+        $cart = WC()->cart;
+        $item = $cart->cart_contents[$cart_item_key];
+        $total = (int) $item['quantity'];
+        $hold_id = (int) ($item['calentra_hold_id'] ?? 0);
+
+        if ($hold_id) {
+            $result = Calentra_Bookings::resize_hold($hold_id, $total);
+            if (is_wp_error($result)) {
+                $cart->cart_contents[$cart_item_key]['quantity'] = $total - (int) $quantity;
+                throw new Exception(esc_html($result->get_error_message()));
+            }
+        } else {
+            $booking = $this->pending;
+            $booking['seats'] = $total;
+            $result = Calentra_Bookings::create_hold($product_id, $booking, self::session_key());
+            if (is_wp_error($result)) {
+                unset($cart->cart_contents[$cart_item_key]);
+                throw new Exception(esc_html($result->get_error_message()));
+            }
+            $cart->cart_contents[$cart_item_key]['calentra_hold_id'] = $result;
+            self::remember_claimed_resource($cart, $cart_item_key, $result);
+        }
+        $this->pending = null;
+    }
+
+    public function release_removed_item($cart_item_key, $cart) {
+        $item = $cart->cart_contents[$cart_item_key] ?? null;
+        if (!empty($item['calentra_hold_id'])) {
+            $hold = Calentra_Bookings::get($item['calentra_hold_id']);
+            // Only a hold; a confirmed booking belongs to an order now.
+            if ($hold && $hold->status === Calentra_Bookings::STATUS_HOLD) {
+                Calentra_Bookings::release($hold->id, 'removed_from_cart');
+            }
+        }
+    }
+
+    public function resize_hold($cart_item_key, $quantity, $old_quantity, $cart) {
+        $item = $cart->cart_contents[$cart_item_key] ?? null;
+        if (empty($item['calentra_hold_id']) || (int) $quantity === (int) $old_quantity) {
+            return;
+        }
+        $result = Calentra_Bookings::resize_hold($item['calentra_hold_id'], $quantity);
+        if (is_wp_error($result) && $result->get_error_code() === 'calentra_slot_taken') {
+            $cart->cart_contents[$cart_item_key]['quantity'] = $old_quantity;
+            wc_add_notice($result->get_error_message(), 'error');
+        }
+        // An expired hold is dealt with by check_cart_holds().
+    }
+
+    /**
+     * Runs whenever the cart or checkout is shown or validated (classic and Store API).
+     * Keeps live holds alive, and re-claims lapsed ones if the slot is still free. A
+     * notice of type error here blocks checkout.
+     */
+    public function check_cart_holds() {
+        $cart = WC()->cart;
+        if (!$cart) {
+            return;
+        }
+        foreach ($cart->get_cart() as $key => $item) {
+            if (empty($item[self::ITEM_KEY])) {
+                continue;
+            }
+            $hold_id = (int) ($item['calentra_hold_id'] ?? 0);
+            $hold = $hold_id ? Calentra_Bookings::get($hold_id) : null;
+
+            if ($hold && $hold->status === Calentra_Bookings::STATUS_HOLD && Calentra_Bookings::extend_hold($hold_id)) {
+                if ((int) $hold->seats !== (int) $item['quantity']) {
+                    $resized = Calentra_Bookings::resize_hold($hold_id, $item['quantity']);
+                    if (is_wp_error($resized)) {
+                        wc_add_notice($resized->get_error_message(), 'error');
+                    }
+                }
+                continue;
+            }
+
+            if ($hold_id) {
+                Calentra_Bookings::expire_if_lapsed($hold_id);
+            }
+            $b = $item[self::ITEM_KEY];
+            $product_id = (int) $item['product_id'];
+
+            // An earlier request may already have re-claimed this slot for the session
+            // without the new ID reaching the stored cart. Adopt it rather than claim twice.
+            $own = Calentra_Bookings::find_session_hold(self::session_key(), $product_id, self::claimed_resource($b), $b['start_utc']);
+            if ($own && Calentra_Bookings::extend_hold($own->id)) {
+                $cart->cart_contents[$key]['calentra_hold_id'] = (int) $own->id;
+                $cart->set_session();
+                continue;
+            }
+            $booking = Calentra_Availability::validate($product_id, [
+                'date'     => $b['date'],
+                'time'     => $b['time'],
+                'end_date' => $b['end_date'],
+                'seats'    => $item['quantity'],
+            ], ['session_key' => self::session_key(), 'resource_id' => (int) $b['resource_id']]);
+            if (!is_wp_error($booking)) {
+                // Validated against the resource the customer asked for (0 for a pool),
+                // but the one they held before is worth trying first.
+                $booking['previous_resource_id'] = self::claimed_resource($b);
+            }
+            $new_id = is_wp_error($booking) ? $booking : Calentra_Bookings::create_hold($product_id, $booking, self::session_key());
+
+            if (is_wp_error($new_id)) {
+                wc_add_notice(sprintf(
+                    /* translators: 1: product name, 2: error message */
+                    __('%1$s: your reservation expired and the slot is no longer available. Please remove it from your cart and choose another time. (%2$s)', 'navarro-booking-for-woocommerce'),
+                    $item['data']->get_name(),
+                    $new_id->get_error_message()
+                ), 'error');
+                continue;
+            }
+            $cart->cart_contents[$key]['calentra_hold_id'] = $new_id;
+            self::remember_claimed_resource($cart, $key, $new_id);
+            $cart->set_session();
+        }
+    }
+
+    /**
+     * Records which resource a hold was actually claimed on. With a pool of resources
+     * (see calentra_claim_resources) that can differ from the requested resource_id,
+     * which stays as asked so a re-claim validates against the whole pool again.
+     * Looking up the session's own hold must use the claimed one, or it would miss the
+     * hold and claim a second seat.
+     */
+    private static function remember_claimed_resource($cart, $cart_item_key, $hold_id) {
+        $hold = Calentra_Bookings::get($hold_id);
+        if ($hold) {
+            $cart->cart_contents[$cart_item_key][self::ITEM_KEY]['claimed_resource_id'] = (int) $hold->resource_id;
+        }
+    }
+
+    private static function claimed_resource(array $b) {
+        return isset($b['claimed_resource_id']) ? (int) $b['claimed_resource_id'] : (int) $b['resource_id'];
+    }
+
+    public function display_cart_item($item_data, $cart_item) {
+        if (empty($cart_item[self::ITEM_KEY])) {
+            return $item_data;
+        }
+        foreach (Calentra_Display::lines($cart_item[self::ITEM_KEY]) as $label => $value) {
+            $item_data[] = ['key' => $label, 'value' => $value];
+        }
+        return $item_data;
+    }
+
+    /**
+     * Multi-day price is the product price per night.
+     */
+    public function apply_multiday_price($cart) {
+        foreach ($cart->get_cart() as $cart_item) {
+            $b = $cart_item[self::ITEM_KEY] ?? null;
+            if (!$b || $b['type'] !== 'multi-day') {
+                continue;
+            }
+            $nights = count(Calentra_Bookings::units_for((object) [
+                'booking_type'  => 'multi-day',
+                'start_utc'     => $b['start_utc'],
+                'end_utc'       => $b['end_utc'],
+                'site_timezone' => $b['timezone'],
+            ]));
+            // Read the price fresh: this hook can run several times per request, and
+            // multiplying the already-adjusted price would compound.
+            $source = wc_get_product($cart_item['variation_id'] ?: $cart_item['product_id']);
+            if (!$source) {
+                continue;
+            }
+            $price = (float) $source->get_price() * max(1, $nights);
+
+            /**
+             * Filters the unit price of a booking in the cart.
+             *
+             * @param float      $price     Price per unit of quantity.
+             * @param array      $cart_item Cart item.
+             * @param WC_Product $product   Product.
+             */
+            $cart_item['data']->set_price(apply_filters('calentra_booking_price', $price, $cart_item, $source));
+        }
+    }
+
+    public function add_order_item_meta($item, $cart_item_key, $values, $order) {
+        if (empty($values[self::ITEM_KEY])) {
+            return;
+        }
+        $b = $values[self::ITEM_KEY];
+        $item->update_meta_data('_calentra_booking_id', (int) ($values['calentra_hold_id'] ?? 0));
+        Calentra_Display::write_item_meta($item, $b);
+    }
+
+    public function confirm_classic_checkout($order_id, $posted_data, $order) {
+        $error = $this->confirm_order($order);
+        if ($error) {
+            throw new Exception(esc_html($error));
+        }
+    }
+
+    public function confirm_store_api_checkout($order) {
+        $error = $this->confirm_order($order);
+        if ($error) {
+            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('calentra_slot_taken', esc_html($error), 409);
+        }
+    }
+
+    /**
+     * Converts every booking hold on the order to confirmed. All or nothing: if one
+     * slot has gone, the others are released again and the order is failed before any
+     * payment is taken.
+     *
+     * @return string|null Error message, or null on success.
+     */
+    private function confirm_order(WC_Order $order) {
+        $confirmed = [];
+        foreach ($order->get_items() as $item_id => $item) {
+            $hold_id = (int) $item->get_meta('_calentra_booking_id');
+            if (!$hold_id) {
+                continue;
+            }
+            $result = Calentra_Bookings::confirm($hold_id, $order->get_id(), $item_id, $item->get_quantity());
+            if (is_wp_error($result)) {
+                foreach ($confirmed as $id) {
+                    Calentra_Bookings::release($id, 'checkout_failed');
+                }
+                $order->update_status('failed', sprintf(
+                    /* translators: %s: product name */
+                    __('Navarro Booking: the booking for %s could not be confirmed because the slot was taken. No payment was attempted.', 'navarro-booking-for-woocommerce'),
+                    $item->get_name()
+                ));
+                return $result->get_error_message();
+            }
+            if ($result !== $hold_id) {
+                $item->update_meta_data('_calentra_booking_id', $result);
+                $item->save();
+            }
+            $confirmed[] = $result;
+        }
+        return null;
+    }
+
+    public function release_order($order_id) {
+        foreach (Calentra_Bookings::for_order($order_id) as $booking) {
+            Calentra_Bookings::release($booking->id, 'order_' . current_action());
+        }
+    }
+
+    public function release_order_post($post_id) {
+        if (in_array(get_post_type($post_id), ['shop_order', 'shop_order_placeholder'], true)) {
+            $this->release_order($post_id);
+        }
+    }
+
+    /**
+     * An order moved from cancelled / failed / refunded back to a live status, for
+     * example a customer retrying a failed payment. Take the slot again if it is free.
+     */
+    public function reclaim_on_restore($order_id, $from, $to) {
+        $dead = ['cancelled', 'failed', 'refunded', 'trash'];
+        if (!in_array($from, $dead, true) || in_array($to, $dead, true)) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        foreach ($order->get_items() as $item_id => $item) {
+            $booking = Calentra_Bookings::get((int) $item->get_meta('_calentra_booking_id'));
+            if (!$booking || $booking->status === Calentra_Bookings::STATUS_CONFIRMED) {
+                continue;
+            }
+            $new_id = Calentra_Bookings::create_confirmed((int) $booking->product_id, [
+                'type'        => $booking->booking_type,
+                'start_utc'   => $booking->start_utc,
+                'end_utc'     => $booking->end_utc,
+                'timezone'    => $booking->site_timezone,
+                'seats'       => (int) $booking->seats,
+                'resource_id' => (int) $booking->resource_id,
+            ], $order_id, $item_id);
+            if (is_wp_error($new_id)) {
+                $order->add_order_note(sprintf(
+                    /* translators: %s: product name */
+                    __('Navarro Booking: the booking for %s could not be restored because the slot has since been booked by someone else.', 'navarro-booking-for-woocommerce'),
+                    $item->get_name()
+                ));
+                continue;
+            }
+            $item->update_meta_data('_calentra_booking_id', $new_id);
+            $item->save();
+        }
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-diagnostics.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-diagnostics.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-diagnostics.php	(revision 3750436)
@@ -0,0 +1,197 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Timezone diagnostics: what the site, the server, the database and the viewer each
+ * think the time is, how upcoming bookings are stored, and where DST changes fall.
+ *
+ * "The booking shows the wrong time" is the most common complaint about booking
+ * plugins, and is usually a timezone mismatch nobody can see. This makes it visible.
+ */
+class Calentra_Diagnostics {
+
+    /**
+     * The diagnostics assets, on the diagnostics tab only. Registered here rather than
+     * printed inline so the browser can cache them and so nothing in this plugin emits a
+     * <script> or <style> tag of its own.
+     *
+     * The viewer's own timezone is the one clock the server cannot know, so the comparison
+     * has to happen in the browser; the site's timezone is handed over as data.
+     */
+    public static function enqueue() {
+        wp_enqueue_style(
+            'calentra-diagnostics',
+            CALENTRA_PLUGIN_URL . 'assets/css/admin-diagnostics.css',
+            [],
+            calentra_asset_version('assets/css/admin-diagnostics.css')
+        );
+        wp_enqueue_script(
+            'calentra-diagnostics',
+            CALENTRA_PLUGIN_URL . 'assets/js/admin-diagnostics.js',
+            [],
+            calentra_asset_version('assets/js/admin-diagnostics.js'),
+            true
+        );
+        wp_localize_script('calentra-diagnostics', 'calentraDiagnostics', [
+            'siteTimezone'          => wp_timezone_string(),
+            'differentTimezoneNote' => __('Your browser is in a different timezone from the site. Customers in your timezone will see both times next to each slot.', 'navarro-booking-for-woocommerce'),
+        ]);
+    }
+
+    public function render() {
+        global $wpdb;
+        $tz = wp_timezone();
+        $tz_name = wp_timezone_string();
+        // Without a timezone_string WordPress falls back to a fixed gmt_offset.
+        $is_offset = !get_option('timezone_string');
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- asking the database for its own clock; there is no API for it and caching would defeat the point.
+        $db_now = $wpdb->get_var('SELECT UTC_TIMESTAMP()');
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- as above.
+        $db_tz = $wpdb->get_var('SELECT @@session.time_zone');
+        $drift = $db_now ? abs(strtotime($db_now . ' UTC') - time()) : null;
+
+        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+        if (!empty($_GET['rebuilt'])) {
+            echo '<div class="notice notice-success"><p>' . esc_html__('Inventory counters rebuilt from bookings.', 'navarro-booking-for-woocommerce') . '</p></div>';
+        }
+        ?>
+        <h2><?php esc_html_e('Clocks', 'navarro-booking-for-woocommerce'); ?></h2>
+        <table class="widefat striped calentra-diag" style="max-width:900px">
+            <tbody>
+            <tr><th><?php esc_html_e('Site timezone (Settings → General)', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><code><?php echo esc_html($tz_name); ?></code>
+                    — <?php echo esc_html(wp_date('Y-m-d H:i T (P)', time(), $tz)); ?>
+                    <?php if ($is_offset) : ?>
+                        <p class="calentra-warn">⚠ <?php esc_html_e('This is a fixed UTC offset, not a city. Fixed offsets do not follow daylight saving time, so slot times will be an hour off for part of the year. Choose a city such as "America/New_York" instead.', 'navarro-booking-for-woocommerce'); ?></p>
+                    <?php endif; ?>
+                </td></tr>
+            <tr><th><?php esc_html_e('Your browser timezone', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><code id="calentra-viewer-tz">…</code> — <span id="calentra-viewer-now">…</span>
+                    <p class="description" id="calentra-viewer-note"></p></td></tr>
+            <tr><th><?php esc_html_e('Server clock (UTC)', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><?php echo esc_html(gmdate('Y-m-d H:i:s')); ?> UTC</td></tr>
+            <tr><th><?php esc_html_e('Database clock (UTC)', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><?php echo esc_html((string) $db_now); ?> UTC · <?php esc_html_e('session time_zone', 'navarro-booking-for-woocommerce'); ?> <code><?php echo esc_html((string) $db_tz); ?></code>
+                    <?php if ($drift !== null && $drift > 60) : ?>
+                        <p class="calentra-warn">⚠ <?php
+                            /* translators: %d: seconds */
+                            printf(esc_html__('The database and web server clocks differ by %d seconds. Hold expiry may be inaccurate.', 'navarro-booking-for-woocommerce'), (int) $drift);
+                        ?></p>
+                    <?php endif; ?></td></tr>
+            <tr><th><?php esc_html_e('How Navarro Booking stores times', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><?php esc_html_e('Every booking is stored as a UTC instant together with the site timezone at the time it was made. Customers see slots in the site timezone, with their own local time alongside when it differs.', 'navarro-booking-for-woocommerce'); ?></td></tr>
+            </tbody>
+        </table>
+
+        <h2><?php esc_html_e('Upcoming daylight-saving changes', 'navarro-booking-for-woocommerce'); ?></h2>
+        <?php
+        $transitions = $tz->getTransitions(time(), time() + YEAR_IN_SECONDS);
+        array_shift($transitions); // The first entry is the current state, not a change.
+        if (!$transitions) {
+            echo '<p>' . esc_html__('None in the next 12 months for this timezone.', 'navarro-booking-for-woocommerce') . '</p>';
+        } else {
+            echo '<ul>';
+            foreach ($transitions as $t) {
+                echo '<li>' . esc_html(wp_date('l, Y-m-d H:i', $t['ts'], $tz)) . ' — '
+                    /* translators: 1: abbreviation, 2: UTC offset in hours */
+                    . esc_html(sprintf(__('clocks change to %1$s (UTC%2$+.1f h)', 'navarro-booking-for-woocommerce'), $t['abbr'], $t['offset'] / 3600)) . '</li>';
+            }
+            echo '</ul>';
+        }
+        ?>
+
+        <h2><?php esc_html_e('Upcoming bookings', 'navarro-booking-for-woocommerce'); ?></h2>
+        <?php
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Navarro Booking's own table; core has no API for it, and a cached read could sell a seat twice.
+        $rows = $wpdb->get_results($wpdb->prepare(
+            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- bookings_table() is a prefix plus a literal; the date below is a placeholder.
+            'SELECT * FROM ' . Calentra_Install::bookings_table() . " WHERE end_utc >= %s AND status IN ('confirmed','hold') ORDER BY start_utc ASC LIMIT 50",
+            gmdate('Y-m-d H:i:s')
+        ));
+        if (!$rows) {
+            echo '<p>' . esc_html__('No upcoming bookings.', 'navarro-booking-for-woocommerce') . '</p>';
+        } else {
+            ?>
+            <div style="overflow-x:auto">
+            <table class="widefat striped calentra-diag">
+                <thead><tr>
+                    <th>#</th>
+                    <th><?php esc_html_e('Product', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Order', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Status', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Stored (UTC)', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Site time', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Your time', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Notes', 'navarro-booking-for-woocommerce'); ?></th>
+                </tr></thead>
+                <tbody>
+                <?php foreach ($rows as $row) :
+                    $btz = Calentra_Display::tz($row->site_timezone);
+                    $start = strtotime($row->start_utc . ' UTC');
+                    $end = strtotime($row->end_utc . ' UTC');
+                    $notes = self::notes($row, $btz, $tz);
+                    ?>
+                    <tr>
+                        <td><?php echo (int) $row->id; ?></td>
+                        <td><?php echo esc_html(get_the_title($row->product_id)); ?></td>
+                        <td><?php if ($row->order_id) : ?><a href="<?php echo esc_url(admin_url('admin.php?page=wc-orders&action=edit&id=' . (int) $row->order_id)); ?>">#<?php echo (int) $row->order_id; ?></a><?php else : ?>—<?php endif; ?></td>
+                        <td><?php echo esc_html($row->status . ($row->status === 'hold' ? ' → ' . $row->hold_expires . ' UTC' : '')); ?></td>
+                        <td><code><?php echo esc_html($row->start_utc); ?></code><br><code><?php echo esc_html($row->end_utc); ?></code></td>
+                        <td><?php echo esc_html(wp_date('Y-m-d H:i T', $start, $btz)); ?><br><?php echo esc_html(wp_date('Y-m-d H:i T', $end, $btz)); ?></td>
+                        <td class="calentra-viewer-time" data-start="<?php echo esc_attr(gmdate('c', $start)); ?>" data-end="<?php echo esc_attr(gmdate('c', $end)); ?>">…</td>
+                        <td><?php echo $notes ? '<span class="calentra-warn">⚠ ' . esc_html(implode(' ', $notes)) . '</span>' : ''; ?></td>
+                    </tr>
+                <?php endforeach; ?>
+                </tbody>
+            </table>
+            </div>
+            <?php
+        }
+        ?>
+        <h2><?php esc_html_e('Inventory', 'navarro-booking-for-woocommerce'); ?></h2>
+        <p><?php esc_html_e('Seat counters are kept alongside bookings so that two customers can never claim the last seat at once. If you have edited the database by hand, rebuild them from the bookings.', 'navarro-booking-for-woocommerce'); ?></p>
+        <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
+            <input type="hidden" name="action" value="calentra_rebuild_inventory">
+            <?php wp_nonce_field('calentra_rebuild_inventory'); ?>
+            <?php submit_button(__('Rebuild inventory counters', 'navarro-booking-for-woocommerce'), 'secondary', 'submit', false); ?>
+        </form>
+        <?php
+    }
+
+    /**
+     * Warnings for one booking: DST inside the booking, a DST change between now and the
+     * booking, and a site timezone that has changed since it was made.
+     */
+    private static function notes($row, DateTimeZone $booking_tz, DateTimeZone $site_tz) {
+        $notes = [];
+        $start = strtotime($row->start_utc . ' UTC');
+        $end = strtotime($row->end_utc . ' UTC');
+
+        // A transition inside the booking itself, or on the day it starts.
+        $day_start = (new DateTimeImmutable('@' . $start))->setTimezone($booking_tz)->setTime(0, 0)->getTimestamp();
+        $inside = $booking_tz->getTransitions(min($start, $day_start), max($end, $day_start + DAY_IN_SECONDS));
+        if (count($inside) > 1) {
+            $notes[] = __('A daylight-saving change falls on this booking\'s date: double-check the time with the customer.', 'navarro-booking-for-woocommerce');
+        }
+        // Different offset from today: the booked hour reads differently in UTC than it would now.
+        $now_offset = $booking_tz->getOffset(new DateTime('now'));
+        $then_offset = $booking_tz->getOffset(new DateTime('@' . $start));
+        if ($now_offset !== $then_offset) {
+            $notes[] = sprintf(
+                /* translators: %+.1f: hours */
+                __('Clocks change before this booking (offset %+.1f h); its UTC time differs from today\'s by that much. This is expected.', 'navarro-booking-for-woocommerce'),
+                ($then_offset - $now_offset) / 3600
+            );
+        }
+        if ($row->site_timezone !== $site_tz->getName()) {
+            $notes[] = sprintf(
+                /* translators: 1: old timezone, 2: new timezone */
+                __('Booked when the site timezone was %1$s; it is now %2$s. The booked instant is unchanged.', 'navarro-booking-for-woocommerce'),
+                $row->site_timezone,
+                $site_tz->getName()
+            );
+        }
+        return $notes;
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-display.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-display.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-display.php	(revision 3750436)
@@ -0,0 +1,114 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Human-readable booking lines for the cart, order screens and emails, and the order
+ * item labels. Times are always shown with their timezone so nobody has to guess.
+ */
+class Calentra_Display {
+
+    const META_DATE = 'calentra_booking_date';
+    const META_TIME = 'calentra_booking_time';
+    const META_END = 'calentra_booking_end_date';
+
+    private $in_admin_item_table = false;
+
+    public function init_hooks() {
+        add_filter('woocommerce_order_item_display_meta_key', [$this, 'label'], 10, 3);
+        add_filter('woocommerce_order_item_get_formatted_meta_data', [$this, 'hide_in_admin'], 10, 2);
+        add_action('woocommerce_before_order_itemmeta', function () {
+            $this->in_admin_item_table = true;
+        });
+        add_action('woocommerce_after_order_itemmeta', function () {
+            $this->in_admin_item_table = false;
+        }, 1);
+    }
+
+    public static function labels() {
+        return [
+            self::META_DATE => __('Booking Date', 'navarro-booking-for-woocommerce'),
+            self::META_TIME => __('Time Slot', 'navarro-booking-for-woocommerce'),
+            self::META_END  => __('End Date', 'navarro-booking-for-woocommerce'),
+        ];
+    }
+
+    /**
+     * @param array $b Booking identity: type, start_utc, end_utc, timezone.
+     * @return array<string, string> Label => value.
+     */
+    public static function lines(array $b) {
+        $values = self::values($b);
+        $out = [];
+        foreach (self::labels() as $key => $label) {
+            if (!empty($values[$key])) {
+                $out[$label] = $values[$key];
+            }
+        }
+        return $out;
+    }
+
+    private static function values(array $b) {
+        $tz = self::tz($b['timezone'] ?? '');
+        $start = strtotime($b['start_utc'] . ' UTC');
+        if ($b['type'] === 'multi-day') {
+            return [
+                self::META_DATE => wp_date(get_option('date_format'), $start, $tz),
+                self::META_END  => wp_date(get_option('date_format'), strtotime($b['end_utc'] . ' UTC'), $tz),
+            ];
+        }
+        return [
+            self::META_DATE => wp_date(get_option('date_format'), $start, $tz),
+            self::META_TIME => self::time_with_zone($start, $tz),
+        ];
+    }
+
+    /** "09:00 EDT", or "09:00 (UTC+08:00)" where the zone has no abbreviation. */
+    public static function time_with_zone($timestamp, DateTimeZone $tz) {
+        $abbr = wp_date('T', $timestamp, $tz);
+        if (preg_match('/^[+-]\d/', $abbr)) {
+            $abbr = '(UTC' . wp_date('P', $timestamp, $tz) . ')';
+        }
+        return wp_date(get_option('time_format') ?: 'H:i', $timestamp, $tz) . ' ' . $abbr;
+    }
+
+    public static function tz($name) {
+        try {
+            return $name ? new DateTimeZone($name) : wp_timezone();
+        } catch (Exception $e) {
+            return wp_timezone();
+        }
+    }
+
+    public static function write_item_meta(WC_Order_Item $item, array $b) {
+        $item->delete_meta_data(self::META_TIME);
+        $item->delete_meta_data(self::META_END);
+        foreach (self::values($b) as $key => $value) {
+            $item->update_meta_data($key, $value);
+        }
+        $item->update_meta_data('_calentra_booking_type', $b['type']);
+    }
+
+    public function label($display_key, $meta, $item) {
+        $labels = self::labels();
+        return $labels[$meta->key] ?? $display_key;
+    }
+
+    /**
+     * Inside the admin order items table the booking is shown, editable, by
+     * Calentra_Admin, so the read-only lines are hidden there. Only there: emails sent
+     * from an admin request must keep them, which a plain is_admin() check would break.
+     */
+    public function hide_in_admin($formatted_meta, $item) {
+        if (!$this->in_admin_item_table) {
+            return $formatted_meta;
+        }
+        $hide = array_merge(array_keys(self::labels()), ['Booking Date', 'Time Slot', 'End Date', 'Booking Type']);
+        foreach ($formatted_meta as $id => $meta) {
+            if (in_array($meta->key, $hide, true)) {
+                unset($formatted_meta[$id]);
+            }
+        }
+        return $formatted_meta;
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-frontend.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-frontend.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-frontend.php	(revision 3750436)
@@ -0,0 +1,106 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Product page: the booking picker and its assets.
+ */
+class Calentra_Frontend {
+
+    public function init_hooks() {
+        add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']);
+        add_action('woocommerce_before_add_to_cart_button', [$this, 'render_booking_fields']);
+        add_filter('woocommerce_loop_add_to_cart_link', [$this, 'force_booking'], 10, 2);
+        add_filter('woocommerce_is_sold_individually', [$this, 'hide_quantity'], 10, 2);
+    }
+
+    public function hide_quantity($sold_individually, $product) {
+        if ($product && $product->get_meta('_calentra_hide_quantity') === 'yes') {
+            return true;
+        }
+        return $sold_individually;
+    }
+
+    public function enqueue_scripts() {
+        // Booking assets are only needed on a product page that actually has
+        // booking switched on. Loading them everywhere made flatpickr throw on
+        // every other page, because the date input it looks for isn't there.
+        if (!function_exists('is_product') || !is_product()) {
+            return;
+        }
+        $config = Calentra_Rules::product_config(get_the_ID());
+        if (!$config['enabled']) {
+            return;
+        }
+
+        wp_enqueue_style('flatpickr', CALENTRA_PLUGIN_URL . 'assets/css/flatpickr.min.css', [], calentra_asset_version('assets/css/flatpickr.min.css'));
+        wp_enqueue_style('calentra-booking', CALENTRA_PLUGIN_URL . 'assets/css/style.css', [], calentra_asset_version('assets/css/style.css'));
+        wp_enqueue_script('flatpickr', CALENTRA_PLUGIN_URL . 'assets/js/flatpickr.min.js', [], calentra_asset_version('assets/js/flatpickr.min.js'), true);
+        wp_enqueue_script('calentra-booking', CALENTRA_PLUGIN_URL . 'assets/js/booking.js', ['jquery', 'flatpickr'], calentra_asset_version('assets/js/booking.js'), true);
+
+        // No nonce here on purpose: the availability endpoint is public read-only data,
+        // and a nonce baked into a cached page is what breaks booking pickers.
+        wp_localize_script('calentra-booking', 'calentra_booking', [
+            'rest_url'      => esc_url_raw(rest_url(Calentra_REST::NS . '/availability')),
+            'product_id'    => get_the_ID(),
+            'type'          => $config['type'],
+            'site_timezone' => $config['timezone']->getName(),
+            'max_days'      => (int) $config['max_days_ahead'],
+            'i18n'          => [
+                'loading'        => __('Loading…', 'navarro-booking-for-woocommerce'),
+                'select_slot'    => __('Select a time slot', 'navarro-booking-for-woocommerce'),
+                'no_slots'       => __('No available slots', 'navarro-booking-for-woocommerce'),
+                'your_time'      => __('your time', 'navarro-booking-for-woocommerce'),
+                'pick_date_time' => __('Please select a booking date and time.', 'navarro-booking-for-woocommerce'),
+                'pick_dates'     => __('Please select both start and end dates.', 'navarro-booking-for-woocommerce'),
+                'load_error'     => __('Availability could not be loaded. Please refresh the page.', 'navarro-booking-for-woocommerce'),
+                /* translators: %s: timezone name */
+                'times_in'       => __('Times shown in %s', 'navarro-booking-for-woocommerce'),
+                /* translators: %d: number of nights */
+                'nights'         => _n_noop('%d night', '%d nights', 'navarro-booking-for-woocommerce'),
+            ],
+        ]);
+    }
+
+    public function force_booking($button, $product) {
+        if ($product->get_meta('_calentra_booking_enabled') != 1 || $product->get_type() !== 'simple') {
+            return $button;
+        }
+        return sprintf(
+            '<a href="%s" class="button wp-element-button add_to_cart_button calentra-book-now">%s</a>',
+            esc_url($product->get_permalink()),
+            esc_html__('Book Now', 'navarro-booking-for-woocommerce')
+        );
+    }
+
+    public function render_booking_fields() {
+        global $product;
+        if (!$product) {
+            return;
+        }
+        $config = Calentra_Rules::product_config($product->get_id());
+        if (!$config['enabled']) {
+            return;
+        }
+        ?>
+        <div class="calentra-booking-fields" data-type="<?php echo esc_attr($config['type']); ?>">
+            <?php if ($config['type'] === 'timeslot') : ?>
+                <label for="calentra_booking_date"><?php esc_html_e('Booking Date', 'navarro-booking-for-woocommerce'); ?></label>
+                <input type="text" id="calentra_booking_date" name="calentra_booking_date" autocomplete="off" required>
+                <label for="calentra_booking_time"><?php esc_html_e('Time Slot', 'navarro-booking-for-woocommerce'); ?></label>
+                <select id="calentra_booking_time" name="calentra_booking_time" required>
+                    <option value=""><?php esc_html_e('Select a date first', 'navarro-booking-for-woocommerce'); ?></option>
+                </select>
+                <p class="calentra-tz-note" id="calentra_tz_note" aria-live="polite"></p>
+            <?php else : ?>
+                <label for="calentra_booking_date"><?php esc_html_e('Start Date', 'navarro-booking-for-woocommerce'); ?></label>
+                <input type="text" id="calentra_booking_date" name="calentra_booking_date" autocomplete="off" required>
+                <label for="calentra_booking_end_date"><?php esc_html_e('End Date', 'navarro-booking-for-woocommerce'); ?></label>
+                <input type="text" id="calentra_booking_end_date" name="calentra_booking_end_date" autocomplete="off" required>
+                <p class="calentra-nights" id="calentra_nights" aria-live="polite"></p>
+            <?php endif; ?>
+            <p class="calentra-error" id="calentra_error" role="alert" hidden></p>
+        </div>
+        <?php
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-ics.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-ics.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-ics.php	(revision 3750436)
@@ -0,0 +1,137 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Attaches an .ics invite for each confirmed booking to order emails.
+ *
+ * Built from the stored UTC instants, so the invite and the slot the customer picked can
+ * no longer disagree about the timezone. The files are written to the system temp
+ * directory and deleted at the end of the request, after the mail has gone.
+ */
+class Calentra_ICS {
+
+    private $tmp_files = [];
+
+    public function init_hooks() {
+        add_filter('woocommerce_email_attachments', [$this, 'attach'], 10, 3);
+        add_action('shutdown', [$this, 'cleanup']);
+    }
+
+    private static function email_ids() {
+        /**
+         * Filters which WooCommerce emails get .ics attachments.
+         *
+         * @param string[] $ids Email IDs.
+         */
+        return apply_filters('calentra_ics_email_ids', [
+            'new_order',
+            'customer_processing_order',
+            'customer_completed_order',
+            'customer_on_hold_order',
+            'customer_invoice',
+        ]);
+    }
+
+    public function attach($attachments, $email_id, $order) {
+        if (!is_a($order, 'WC_Order') || !in_array($email_id, self::email_ids(), true)) {
+            return $attachments;
+        }
+        foreach (Calentra_Bookings::for_order($order->get_id()) as $booking) {
+            if ($booking->status !== Calentra_Bookings::STATUS_CONFIRMED) {
+                continue;
+            }
+            $item = $booking->order_item_id ? $order->get_item($booking->order_item_id) : null;
+            $file = $this->write_file($booking, $item ? $item->get_name() : get_the_title($booking->product_id));
+            if ($file) {
+                $attachments[] = $file;
+            }
+        }
+        return $attachments;
+    }
+
+    public static function build($booking, $summary) {
+        /**
+         * Filters the event title in a booking's .ics invite.
+         *
+         * @param string $summary Default: the product (order item) name.
+         * @param object $booking The booking row.
+         */
+        $summary = (string) apply_filters('calentra_ics_summary', $summary, $booking);
+        $host = wp_parse_url(home_url(), PHP_URL_HOST) ?: 'localhost';
+        $site = wp_strip_all_tags(get_bloginfo('name'));
+        $start = strtotime($booking->start_utc . ' UTC');
+        $end = strtotime($booking->end_utc . ' UTC');
+
+        if ($booking->booking_type === 'multi-day') {
+            // All-day events are floating dates; use the site-local calendar dates.
+            $tz = Calentra_Display::tz($booking->site_timezone);
+            $dtstart = 'DTSTART;VALUE=DATE:' . wp_date('Ymd', $start, $tz);
+            $dtend = 'DTEND;VALUE=DATE:' . wp_date('Ymd', $end, $tz);
+        } else {
+            $dtstart = 'DTSTART:' . gmdate('Ymd\THis\Z', $start);
+            $dtend = 'DTEND:' . gmdate('Ymd\THis\Z', $end);
+        }
+
+        $lines = [
+            'BEGIN:VCALENDAR',
+            'VERSION:2.0',
+            'PRODID:-//Dominick Navarro//Navarro Booking ' . CALENTRA_VERSION . '//EN',
+            'CALSCALE:GREGORIAN',
+            'METHOD:PUBLISH',
+            'BEGIN:VEVENT',
+            // Stable per booking, so a re-sent email updates the event instead of duplicating it.
+            'UID:calentra-' . (int) $booking->id . '@' . $host,
+            'DTSTAMP:' . gmdate('Ymd\THis\Z'),
+            $dtstart,
+            $dtend,
+            'SUMMARY:' . self::escape($summary),
+            /* translators: 1: site name, 2: product name */
+            'DESCRIPTION:' . self::escape(sprintf(__('Booking from %1$s for %2$s', 'navarro-booking-for-woocommerce'), $site, $summary)),
+            'END:VEVENT',
+            'END:VCALENDAR',
+        ];
+        return implode("\r\n", array_map([__CLASS__, 'fold'], $lines)) . "\r\n";
+    }
+
+    private function write_file($booking, $summary) {
+        $path = trailingslashit(get_temp_dir()) . 'calentra-booking-' . (int) $booking->id . '-' . wp_generate_password(8, false) . '.ics';
+        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file, removed at shutdown.
+        if (false === file_put_contents($path, self::build($booking, $summary))) {
+            return null;
+        }
+        $this->tmp_files[] = $path;
+        return $path;
+    }
+
+    public function cleanup() {
+        foreach ($this->tmp_files as $file) {
+            wp_delete_file($file);
+        }
+        $this->tmp_files = [];
+    }
+
+    /** RFC 5545 TEXT escaping. */
+    private static function escape($text) {
+        $text = wp_strip_all_tags((string) $text);
+        return str_replace(["\\", ';', ',', "\r\n", "\n"], ["\\\\", '\;', '\,', '\n', '\n'], $text);
+    }
+
+    /** RFC 5545 line folding at 75 octets. */
+    private static function fold($line) {
+        if (strlen($line) <= 75) {
+            return $line;
+        }
+        $out = '';
+        while (strlen($line) > 75) {
+            $cut = 75;
+            // Don't split a multibyte character.
+            while ($cut > 0 && (ord($line[$cut]) & 0xC0) === 0x80) {
+                $cut--;
+            }
+            $out .= substr($line, 0, $cut) . "\r\n ";
+            $line = substr($line, $cut);
+        }
+        return $out . $line;
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-install.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-install.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-install.php	(revision 3750436)
@@ -0,0 +1,350 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/*
+ * Schema work: creating, renaming and migrating Navarro Booking's own tables. WordPress has
+ * no API for any of it, so the direct-query, caching and schema-change sniffs are
+ * switched off for the file, on the same invariant used in class-calentra-bookings.php:
+ * table names come only from this class's own bookings_table() / inventory_table()
+ * helpers, which are $wpdb->prefix plus a literal, and every value is a placeholder
+ * passed through $wpdb->prepare(). Caching a migration would be meaningless.
+ */
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
+// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+/**
+ * Schema and upgrades.
+ *
+ * v1.1.0 stored bookings as a local DATE + TIME with no status. 2.0 stores UTC instants,
+ * a status (hold | confirmed | cancelled | expired) and a seat count, and keeps a
+ * per-slot inventory counter so claims can be made atomically.
+ */
+class Calentra_Install {
+
+    const DB_VERSION = '2.4.0';
+    const DB_VERSION_OPTION = 'calentra_db_version';
+
+    public static function bookings_table() {
+        global $wpdb;
+        return $wpdb->prefix . 'calentra_bookings';
+    }
+
+    public static function inventory_table() {
+        global $wpdb;
+        return $wpdb->prefix . 'calentra_slot_inventory';
+    }
+
+    public static function legacy_table() {
+        global $wpdb;
+        return $wpdb->prefix . 'calentra_bookings_v1';
+    }
+
+    public static function install() {
+        if (!class_exists('WooCommerce')) {
+            return; // Runs on the first request after WooCommerce is active instead.
+        }
+        Calentra_Loader::includes();
+        self::maybe_upgrade();
+    }
+
+    public static function maybe_upgrade() {
+        if (get_option(self::DB_VERSION_OPTION) === self::DB_VERSION) {
+            return;
+        }
+
+        // Two requests can land here at once right after an update; only one may migrate.
+        if (!add_option('calentra_upgrade_lock', time(), '', false)) {
+            if (time() - (int) get_option('calentra_upgrade_lock') < 300) {
+                return;
+            }
+            update_option('calentra_upgrade_lock', time(), false);
+        }
+
+        $previous = (string) get_option(self::DB_VERSION_OPTION);
+        $had_legacy = self::retire_legacy_table();
+        // retire_legacy_table() is also true whenever the v1 backup table is still there,
+        // long after it was migrated; only a migration that actually runs rebuilds.
+        $migrating_v1 = $had_legacy && !get_option('calentra_v1_migrated');
+        self::create_tables();
+        if ($had_legacy) {
+            self::migrate_from_v1();
+        }
+        if (!$migrating_v1 && $previous !== '' && version_compare($previous, '2.4.0', '<')) {
+            // A v1 migration's rebuild_inventory() writes the resource-wide rows itself.
+            self::backfill_resource_inventory();
+        }
+
+        update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
+        delete_option('calentra_upgrade_lock');
+    }
+
+    private static function create_tables() {
+        global $wpdb;
+        $bookings = self::bookings_table();
+        $inventory = self::inventory_table();
+        $charset_collate = $wpdb->get_charset_collate();
+
+        $sql = "CREATE TABLE $bookings (
+            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+            product_id BIGINT UNSIGNED NOT NULL,
+            resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
+            booking_type VARCHAR(20) NOT NULL DEFAULT 'timeslot',
+            order_id BIGINT UNSIGNED NULL,
+            order_item_id BIGINT UNSIGNED NULL,
+            status VARCHAR(20) NOT NULL,
+            start_utc DATETIME NOT NULL,
+            end_utc DATETIME NOT NULL,
+            site_timezone VARCHAR(64) NOT NULL,
+            seats INT UNSIGNED NOT NULL DEFAULT 1,
+            hold_expires DATETIME NULL,
+            session_key VARCHAR(64) NULL,
+            created_at DATETIME NOT NULL,
+            updated_at DATETIME NOT NULL,
+            PRIMARY KEY  (id),
+            KEY idx_lookup (product_id,resource_id,start_utc,status),
+            KEY idx_holds (status,hold_expires),
+            KEY idx_order (order_id),
+            KEY idx_session (session_key),
+            KEY idx_resource (resource_id,start_utc)
+        ) $charset_collate;
+        CREATE TABLE $inventory (
+            product_id BIGINT UNSIGNED NOT NULL,
+            resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
+            start_utc DATETIME NOT NULL,
+            seats_total INT UNSIGNED NOT NULL,
+            seats_used INT UNSIGNED NOT NULL DEFAULT 0,
+            PRIMARY KEY  (product_id,resource_id,start_utc)
+        ) $charset_collate;";
+
+        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+        dbDelta($sql);
+    }
+
+    /**
+     * Moves a v1.1.0 table (DATE + TIME columns) aside so the new one can be created.
+     * The old table is kept as a backup and removed on uninstall.
+     */
+    private static function retire_legacy_table() {
+        global $wpdb;
+        $table = self::bookings_table();
+        $legacy = self::legacy_table();
+
+        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $legacy)) === $legacy) {
+            // A previous migration was interrupted after the rename; finish it.
+            return true;
+        }
+        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
+            return false;
+        }
+        $has_old_column = $wpdb->get_var($wpdb->prepare(
+            "SHOW COLUMNS FROM `$table` LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+            'booking_date'
+        ));
+        if (!$has_old_column) {
+            return false;
+        }
+        $wpdb->query("RENAME TABLE `$table` TO `$legacy`"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+        return true;
+    }
+
+    /**
+     * Backfills 2.0 rows from two v1.1.0 sources:
+     *  - the old bookings table (orders placed before the multi-day release), and
+     *  - order line item meta. From the multi-day release onwards, checkout saved the
+     *    booking under "Booking Date" / "Time Slot" / "End Date", while store_booking()
+     *    read "calentra_booking_date", so those bookings never reached the table at all.
+     *
+     * Local times are interpreted in the site timezone, which is what v1.1.0 meant by them.
+     */
+    private static function migrate_from_v1() {
+        global $wpdb;
+        $legacy = self::legacy_table();
+        $tz = wp_timezone();
+        $seen = [];
+
+        if (get_option('calentra_v1_migrated')) {
+            return;
+        }
+
+        $rows = $wpdb->get_results("SELECT product_id, booking_date, booking_time, order_id FROM `$legacy`");
+        foreach ((array) $rows as $row) {
+            $key = $row->order_id . ':' . $row->product_id;
+            $seen[$key] = true;
+            self::insert_migrated($row->product_id, (int) $row->order_id, 0, 'timeslot', $row->booking_date, substr($row->booking_time, 0, 5), null, $tz);
+        }
+
+        // Line items carrying booking meta under either the v1.0 or v1.1 key names.
+        $items = $wpdb->get_results(
+            "SELECT i.order_item_id, i.order_id,
+                    MAX(CASE WHEN m.meta_key IN ('Booking Date','calentra_booking_date') THEN m.meta_value END) AS bdate,
+                    MAX(CASE WHEN m.meta_key IN ('Time Slot','calentra_booking_time') THEN m.meta_value END) AS btime,
+                    MAX(CASE WHEN m.meta_key = 'End Date' THEN m.meta_value END) AS bend,
+                    MAX(CASE WHEN m.meta_key = 'Booking Type' THEN m.meta_value END) AS btype,
+                    MAX(CASE WHEN m.meta_key = '_product_id' THEN m.meta_value END) AS product_id
+               FROM {$wpdb->prefix}woocommerce_order_items i
+               JOIN {$wpdb->prefix}woocommerce_order_itemmeta m ON m.order_item_id = i.order_item_id
+              WHERE i.order_item_type = 'line_item'
+           GROUP BY i.order_item_id, i.order_id
+             HAVING bdate IS NOT NULL"
+        );
+        foreach ((array) $items as $item) {
+            $key = $item->order_id . ':' . $item->product_id;
+            if (isset($seen[$key])) {
+                $wpdb->update(self::bookings_table(), ['order_item_id' => $item->order_item_id], ['order_id' => $item->order_id, 'product_id' => $item->product_id]);
+                $booking_id = $wpdb->get_var($wpdb->prepare(
+                    'SELECT id FROM ' . self::bookings_table() . ' WHERE order_id = %d AND product_id = %d',
+                    $item->order_id, $item->product_id
+                ));
+                if ($booking_id) {
+                    wc_update_order_item_meta($item->order_item_id, '_calentra_booking_id', (int) $booking_id);
+                }
+                continue;
+            }
+            $type = $item->btype === 'multi-day' ? 'multi-day' : 'timeslot';
+            self::insert_migrated($item->product_id, (int) $item->order_id, (int) $item->order_item_id, $type, $item->bdate, $item->btime, $item->bend, $tz);
+        }
+
+        // v1.1.0 hooked save_post for every post type, so pages and posts saved since
+        // then carry empty booking meta. Only products should.
+        $wpdb->query(
+            "DELETE m FROM {$wpdb->postmeta} m JOIN {$wpdb->posts} p ON p.ID = m.post_id
+              WHERE m.meta_key LIKE '\\_calentra\\_%' AND p.post_type NOT IN ('product', 'product_variation')"
+        );
+
+        self::rebuild_inventory();
+        update_option('calentra_v1_migrated', 1, false);
+    }
+
+    private static function insert_migrated($product_id, $order_id, $item_id, $type, $date, $time, $end_date, DateTimeZone $tz) {
+        global $wpdb;
+        try {
+            if ($type === 'multi-day') {
+                if (!$end_date) {
+                    return;
+                }
+                $start = new DateTimeImmutable($date . ' 00:00', $tz);
+                $end = new DateTimeImmutable($end_date . ' 00:00', $tz);
+                if ($end <= $start) {
+                    $end = $start->modify('+1 day');
+                }
+            } else {
+                if (!$time) {
+                    return;
+                }
+                $start = new DateTimeImmutable($date . ' ' . $time, $tz);
+                $duration = (int) get_post_meta($product_id, '_calentra_duration', true) ?: 60;
+                $end = $start->modify("+{$duration} minutes");
+            }
+        } catch (Exception $e) {
+            return;
+        }
+
+        $status = 'confirmed';
+        $order = $order_id ? wc_get_order($order_id) : null;
+        if ($order && in_array($order->get_status(), ['cancelled', 'failed', 'refunded'], true)) {
+            $status = 'cancelled';
+        }
+
+        $utc = new DateTimeZone('UTC');
+        $now = gmdate('Y-m-d H:i:s');
+        $wpdb->insert(self::bookings_table(), [
+            'product_id'    => (int) $product_id,
+            'resource_id'   => 0,
+            'booking_type'  => $type,
+            'order_id'      => $order_id ?: null,
+            'order_item_id' => $item_id ?: null,
+            'status'        => $status,
+            'start_utc'     => $start->setTimezone($utc)->format('Y-m-d H:i:s'),
+            'end_utc'       => $end->setTimezone($utc)->format('Y-m-d H:i:s'),
+            'site_timezone' => $tz->getName(),
+            'seats'         => 1,
+            'created_at'    => $now,
+            'updated_at'    => $now,
+        ]);
+        if ($item_id && $wpdb->insert_id) {
+            wc_update_order_item_meta($item_id, '_calentra_booking_id', $wpdb->insert_id);
+        }
+    }
+
+    /**
+     * Recomputes every inventory counter from the bookings that hold seats: confirmed
+     * ones and every hold, lapsed or not. Used after migration and available from the
+     * diagnostics screen.
+     *
+     * A lapsed hold is still counted until it is retired, and retiring it gives its seats
+     * back (Calentra_Bookings::transition()). Leaving it out here would let that later
+     * give-back remove a seat that was never counted, so the counter would fall below
+     * the bookings it covers and the slot could be oversold.
+     */
+    public static function rebuild_inventory() {
+        global $wpdb;
+        $wpdb->query('DELETE FROM ' . self::inventory_table());
+        $rows = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM %i WHERE status = 'confirmed' OR status = 'hold'",
+            self::bookings_table()
+        ));
+        foreach ((array) $rows as $row) {
+            foreach (Calentra_Bookings::units_for($row) as $unit) {
+                self::add_seats($row->product_id, $row->resource_id, $unit, $row->seats);
+            }
+            if ((int) $row->resource_id > 0) {
+                foreach ((array) Calentra_Bookings::resource_units_for($row) as $unit) {
+                    self::add_seats(0, $row->resource_id, $unit, $row->seats);
+                }
+            }
+        }
+    }
+
+    /**
+     * 2.4.0: writes the resource-wide rows (product_id = 0) that bookings on a resource
+     * made before 2.4.0 never claimed.
+     *
+     * Adds to the rows rather than setting them. Requests already running 2.4.0 claim
+     * shared rows while this runs, and a claim takes its seat before its booking row
+     * exists. Setting a row to the sum of bookings would drop such an in-flight seat,
+     * an oversell. Adding can only count a booking made by 2.4.0 in this window twice,
+     * a seat that stays unsellable until "Rebuild inventory" on the diagnostics screen
+     * recomputes it. A perfectly clean upgrade means pausing the shop, or running the
+     * upgrade with WP-CLI before traffic reaches it.
+     *
+     * calentra_upgrade_lock in maybe_upgrade() keeps two of these from running at once.
+     *
+     * Only bookings that have not ended: a past slot is never claimed again, and a
+     * give-back on a row that doesn't exist changes nothing. Every hold is counted,
+     * lapsed or not, for the reason given in rebuild_inventory().
+     */
+    private static function backfill_resource_inventory() {
+        global $wpdb;
+        $rows = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM %i WHERE resource_id > 0 AND end_utc > %s AND (status = 'confirmed' OR status = 'hold')",
+            self::bookings_table(), gmdate('Y-m-d H:i:s')
+        ));
+        $seats = [];
+        foreach ((array) $rows as $row) {
+            // Multi-day bookings occupy one unit per night, as units_for() expands them.
+            foreach ((array) Calentra_Bookings::resource_units_for($row) as $unit) {
+                $key = (int) $row->resource_id . '|' . $unit;
+                $seats[$key] = ($seats[$key] ?? 0) + (int) $row->seats;
+            }
+        }
+        foreach ($seats as $key => $count) {
+            [$resource_id, $unit] = explode('|', $key, 2);
+            self::add_seats(0, (int) $resource_id, $unit, $count);
+        }
+    }
+
+    private static function add_seats($product_id, $resource_id, $unit, $seats) {
+        global $wpdb;
+        $capacity = Calentra_Availability::capacity($product_id, $resource_id, $unit);
+        $wpdb->query($wpdb->prepare(
+            'INSERT INTO %i (product_id, resource_id, start_utc, seats_total, seats_used) VALUES (%d, %d, %s, %d, %d)
+             ON DUPLICATE KEY UPDATE seats_used = seats_used + VALUES(seats_used)',
+            self::inventory_table(), $product_id, $resource_id, $unit, $capacity, $seats
+        ));
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-loader.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-loader.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-loader.php	(revision 3750436)
@@ -0,0 +1,52 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+class Calentra_Loader {
+
+    public static function includes() {
+        foreach ([
+            'class-calentra-rules.php',
+            'class-calentra-availability.php',
+            'class-calentra-bookings.php',
+            'class-calentra-display.php',
+            'class-calentra-rest.php',
+            'class-calentra-cart.php',
+            'class-calentra-ics.php',
+            'class-calentra-frontend.php',
+            'class-calentra-admin.php',
+            'class-calentra-diagnostics.php',
+            'class-calentra-upgrade.php',
+        ] as $file) {
+            require_once CALENTRA_PLUGIN_PATH . 'includes/' . $file;
+        }
+    }
+
+    public function run() {
+        self::includes();
+
+        // No load_plugin_textdomain() call: WordPress has loaded translations for a
+        // plugin's own text domain automatically since 4.6, and calling it by hand is
+        // now flagged by Plugin Check.
+
+        // Cart, checkout, REST and order-status hooks run in every context: the block
+        // checkout goes through the REST API and statuses usually change in the admin.
+        (new Calentra_REST())->init_hooks();
+        (new Calentra_Cart())->init_hooks();
+        (new Calentra_Display())->init_hooks();
+        (new Calentra_ICS())->init_hooks();
+
+        if (is_admin()) {
+            (new Calentra_Admin())->init_hooks();
+            (new Calentra_Upgrade())->init_hooks();
+        } else {
+            (new Calentra_Frontend())->init_hooks();
+        }
+
+        // Optional tidy-up of lapsed holds. Nothing depends on it running.
+        add_action('calentra_sweep', ['Calentra_Bookings', 'sweep']);
+        if (!wp_next_scheduled('calentra_sweep')) {
+            wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'calentra_sweep');
+        }
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-rest.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-rest.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-rest.php	(revision 3750436)
@@ -0,0 +1,103 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Public, nonce-free, GET availability endpoint.
+ *
+ * Availability is public read-only data, so it needs no nonce, and without one there is
+ * nothing for a page cache to freeze: a cached product page keeps working for as long
+ * as it stays cached. Responses carry an ETag and a short Cache-Control.
+ *
+ * GET /wp-json/calentra/v1/availability?product_id=12&from=2026-10-01&to=2026-10-31[&resource_id=3]
+ */
+class Calentra_REST {
+
+    const NS = 'calentra/v1';
+    const MAX_AGE = 30;
+
+    public function init_hooks() {
+        add_action('rest_api_init', [$this, 'register_routes']);
+    }
+
+    public function register_routes() {
+        register_rest_route(self::NS, '/availability', [
+            'methods'             => WP_REST_Server::READABLE,
+            'callback'            => [$this, 'availability'],
+            'permission_callback' => '__return_true',
+            'args'                => [
+                'product_id'  => ['required' => true, 'type' => 'integer', 'minimum' => 1],
+                'from'        => ['required' => true, 'type' => 'string', 'pattern' => '^\d{4}-\d{2}-\d{2}$'],
+                'to'          => ['required' => true, 'type' => 'string', 'pattern' => '^\d{4}-\d{2}-\d{2}$'],
+                // 0 is the product itself. Which resources belong to a product is an
+                // add-on's business; core answers for whichever resource it is asked about.
+                'resource_id' => ['required' => false, 'type' => 'integer', 'minimum' => 0, 'default' => 0],
+            ],
+        ]);
+    }
+
+    public function availability(WP_REST_Request $request) {
+        $product_id = (int) $request['product_id'];
+        $product = wc_get_product($product_id);
+        if (!$product || $product->get_status() !== 'publish') {
+            return new WP_Error('calentra_not_found', __('Product not found.', 'navarro-booking-for-woocommerce'), ['status' => 404]);
+        }
+
+        $session_key = self::session_key_from_cookie();
+        $resource_id = (int) $request['resource_id'];
+        $args = ['session_key' => $session_key, 'resource_id' => $resource_id];
+
+        if ($session_key === '') {
+            // Anonymous responses are identical for everyone, so they can be cached
+            // server-side until a booking or a rule changes (or the TTL lapses, which
+            // covers slots passing and holds expiring).
+            $cache_key = 'calentra_av_' . md5(implode('|', [
+                // Each resource has its own bookings, so it needs its own cache entry.
+                $product_id, $resource_id, $request['from'], $request['to'],
+                Calentra_Availability::cache_version($product_id),
+                (int) get_option('calentra_global_cache_version'),
+            ]));
+            $data = get_transient($cache_key);
+            if ($data === false) {
+                $data = Calentra_Availability::get($product_id, $request['from'], $request['to'], $args);
+                if (!is_wp_error($data)) {
+                    set_transient($cache_key, $data, self::MAX_AGE);
+                }
+            }
+        } else {
+            $data = Calentra_Availability::get($product_id, $request['from'], $request['to'], $args);
+        }
+
+        if (is_wp_error($data)) {
+            return $data;
+        }
+
+        $etag = '"' . md5(wp_json_encode($data)) . '"';
+        $response = new WP_REST_Response($data);
+        $response->header('ETag', $etag);
+        $response->header('Cache-Control', $session_key === ''
+            ? 'public, max-age=' . self::MAX_AGE
+            : 'private, no-cache');
+        $response->header('Vary', 'Cookie');
+
+        $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_IF_NONE_MATCH']))) : '';
+        if ($if_none_match !== '' && $if_none_match === $etag) {
+            $response->set_status(304);
+            $response->set_data(null);
+        }
+        return $response;
+    }
+
+    /**
+     * The WooCommerce cart session from its cookie, verified. REST requests do not load
+     * the Woo session, and we only need its key to let a customer see their own holds.
+     */
+    public static function session_key_from_cookie() {
+        if (!class_exists('WC_Session_Handler')) {
+            return '';
+        }
+        $handler = new WC_Session_Handler();
+        $cookie = $handler->get_session_cookie();
+        return $cookie ? (string) $cookie[0] : '';
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-rules.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-rules.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-rules.php	(revision 3750436)
@@ -0,0 +1,133 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Reads, parses and validates booking configuration. Everything that decides what a
+ * product's bookable slots are goes through here, so the availability read path and the
+ * add-to-cart / checkout write paths agree by construction.
+ */
+class Calentra_Rules {
+
+    const DEFAULT_DAYS_AHEAD = 365;
+    const MAX_DAYS_AHEAD = 1825;
+
+    /**
+     * Effective configuration for a product: product meta layered over global options.
+     */
+    public static function product_config($product_id) {
+        $product_id = (int) $product_id;
+        $config = [
+            'enabled'       => '1' === (string) get_post_meta($product_id, '_calentra_booking_enabled', true),
+            'type'          => get_post_meta($product_id, '_calentra_booking_type', true) === 'multi-day' ? 'multi-day' : 'timeslot',
+            'start'         => self::valid_time(get_post_meta($product_id, '_calentra_start_time', true)) ?: (self::valid_time(get_option('calentra_global_start')) ?: '09:00'),
+            'end'           => self::valid_time(get_post_meta($product_id, '_calentra_end_time', true)) ?: (self::valid_time(get_option('calentra_global_end')) ?: '17:00'),
+            'duration'      => max(1, (int) get_post_meta($product_id, '_calentra_duration', true) ?: 60),
+            'blocked_dates' => array_values(array_unique(array_merge(
+                self::parse_dates(get_post_meta($product_id, '_calentra_blocked_dates', true))['valid'],
+                self::parse_dates(get_option('calentra_global_blocked_dates', []))['valid']
+            ))),
+            'blocked_slots' => array_values(array_unique(array_merge(
+                self::parse_slots(get_post_meta($product_id, '_calentra_blocked_slots', true))['valid'],
+                self::parse_slots(get_option('calentra_global_blocked_slots', []))['valid']
+            ))),
+            'timezone'      => wp_timezone(),
+            // How far ahead customers may book. Without a limit a crafted request could
+            // ask for a slot in the year 9999.
+            'max_days_ahead' => self::max_days_ahead($product_id),
+        ];
+
+        /**
+         * Filters a product's effective booking configuration.
+         *
+         * @param array $config     See Calentra_Rules::product_config().
+         * @param int   $product_id Product ID.
+         */
+        return apply_filters('calentra_product_config', $config, $product_id);
+    }
+
+    /**
+     * Booking horizon in days: the product's own setting, else the global default.
+     */
+    public static function max_days_ahead($product_id) {
+        $days = self::clamp_days_ahead(get_post_meta($product_id, '_calentra_max_days_ahead', true));
+        if (!$days) {
+            $days = self::clamp_days_ahead(get_option('calentra_max_days_ahead', self::DEFAULT_DAYS_AHEAD)) ?: self::DEFAULT_DAYS_AHEAD;
+        }
+        return $days;
+    }
+
+    /** 1 to MAX_DAYS_AHEAD, or 0 for "not set". */
+    public static function clamp_days_ahead($value) {
+        $days = absint($value);
+        return $days ? min($days, self::MAX_DAYS_AHEAD) : 0;
+    }
+
+    public static function valid_time($value) {
+        $value = is_string($value) ? trim($value) : '';
+        if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $value)) {
+            return $value;
+        }
+        return '';
+    }
+
+    public static function valid_date($value) {
+        $value = is_string($value) ? trim($value) : '';
+        if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m)) {
+            return '';
+        }
+        return checkdate((int) $m[2], (int) $m[3], (int) $m[1]) ? $value : '';
+    }
+
+    /**
+     * Accepts an array or the comma-separated string v1.1.0 stored.
+     *
+     * @return array{valid: string[], invalid: string[]}
+     */
+    public static function parse_dates($raw) {
+        $out = ['valid' => [], 'invalid' => []];
+        foreach (self::split($raw) as $entry) {
+            if (self::valid_date($entry)) {
+                $out['valid'][] = $entry;
+            } else {
+                $out['invalid'][] = $entry;
+            }
+        }
+        $out['valid'] = array_values(array_unique($out['valid']));
+        sort($out['valid']);
+        return $out;
+    }
+
+    /**
+     * Blocked slots are "YYYY-MM-DD HH:MM" in site time.
+     *
+     * @return array{valid: string[], invalid: string[]}
+     */
+    public static function parse_slots($raw) {
+        $out = ['valid' => [], 'invalid' => []];
+        foreach (self::split($raw) as $entry) {
+            $entry = preg_replace('/\s+/', ' ', $entry);
+            $parts = explode(' ', $entry);
+            if (count($parts) === 2 && self::valid_date($parts[0]) && self::valid_time($parts[1])) {
+                $out['valid'][] = $entry;
+            } else {
+                $out['invalid'][] = $entry;
+            }
+        }
+        $out['valid'] = array_values(array_unique($out['valid']));
+        sort($out['valid']);
+        return $out;
+    }
+
+    private static function split($raw) {
+        if (is_array($raw)) {
+            $items = $raw;
+        } else {
+            $items = explode(',', (string) $raw);
+        }
+        $items = array_map(function ($v) {
+            return trim(sanitize_text_field((string) $v));
+        }, $items);
+        return array_values(array_filter($items, 'strlen'));
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-upgrade.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-upgrade.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/includes/class-calentra-upgrade.php	(revision 3750436)
@@ -0,0 +1,233 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Where Navarro Booking Pro's features would be, shown in place on Navarro Booking's own
+ * screens, and explained only when clicked.
+ *
+ * One registry drives every teaser: a feature has an id, a title (the real label of the
+ * control it stands in for), a one-sentence blurb and a location. A new teaser is a new
+ * entry; render() at an existing location picks it up.
+ *
+ * Rules this class keeps: nothing opens unless clicked (a <details> element, no script);
+ * no admin notices, no dashboard widget, no prompt on activation; a locked control is
+ * never an input, so it can't look as if it accepts a value; no telemetry.
+ *
+ * "Learn more" goes to the calentra_upgrade_url option when it is set, and otherwise to
+ * the Navarro Booking Pro page. This plugin bundles no licensing or update code: Pro is
+ * a separate plugin, bought and licensed there.
+ */
+class Calentra_Upgrade {
+
+    /** Overrides where "learn more" goes. Never written by this plugin; unset by default. */
+    const URL_OPTION = 'calentra_upgrade_url';
+
+    /** The Navarro Booking Pro page, where it is described and sold. */
+    const PRO_URL = 'https://dominicknavarro.com/navarro-booking-pro';
+
+    /** Anchor of the Pro row at the foot of the settings page. */
+    const ANCHOR = 'calentra-upgrade';
+
+    /** Product booking panel, below the start and end times. */
+    const LOCATION_PRODUCT_TIMES = 'product_times';
+    /** Product booking panel, below the duration. */
+    const LOCATION_PRODUCT_CAPACITY = 'product_capacity';
+    /** Product booking panel, at the end. */
+    const LOCATION_PRODUCT_END = 'product_end';
+    /** Settings page, after the built-in settings. */
+    const LOCATION_SETTINGS = 'settings';
+    /** WooCommerce → Bookings, beside the page title. */
+    const LOCATION_BOOKINGS_ACTIONS = 'bookings_actions';
+
+    public function init_hooks() {
+        add_filter('plugin_action_links_' . plugin_basename(CALENTRA_PLUGIN_FILE), [$this, 'action_links']);
+        add_action('admin_enqueue_scripts', [$this, 'enqueue']);
+    }
+
+    /**
+     * The registry, in display order within each location. Built on each call so the
+     * strings are translated in the current locale.
+     *
+     * Only features Navarro Booking Pro actually has belong here: each entry says "Part
+     * of Navarro Booking Pro". A feature gets its entry when its Pro release ships.
+     *
+     * @return array<string, array{title: string, blurb: string, location: string}>
+     */
+    public static function features() {
+        return [
+            'resources' => [
+                'title'    => __('Resources', 'navarro-booking-for-woocommerce'),
+                'blurb'    => __('Book staff, rooms or equipment, chosen by the customer or assigned to whichever is free.', 'navarro-booking-for-woocommerce'),
+                'location' => self::LOCATION_PRODUCT_END,
+            ],
+        ];
+    }
+
+    /**
+     * Whether a feature is provided, so its teaser must not appear. Navarro Booking Pro
+     * answers for the features it actually has; nothing else in this plugin decides it.
+     */
+    public static function unlocked($id) {
+        /**
+         * Filters whether a feature shown as locked in the free plugin is available.
+         *
+         * @param bool   $unlocked Default false.
+         * @param string $id       Feature id from Calentra_Upgrade::features().
+         */
+        return (bool) apply_filters('calentra_feature_unlocked', false, (string) $id);
+    }
+
+    /**
+     * Where "learn more" goes, or '' for nowhere.
+     */
+    public static function url() {
+        $url = (string) get_option(self::URL_OPTION, '');
+
+        if ($url === '') {
+            $url = self::PRO_URL;
+        }
+
+        /**
+         * Filters the URL that explains Navarro Booking Pro.
+         *
+         * @param string $url The calentra_upgrade_url option, or the Navarro Booking Pro
+         *                    page when that option is unset. Return '' for no link.
+         */
+        $url = (string) apply_filters('calentra_upgrade_url', $url);
+
+        return $url === '' ? '' : esc_url_raw($url, ['https', 'http']);
+    }
+
+    /**
+     * Whether a URL stays inside this site's admin, in which case the link must not open
+     * a new tab: a new tab for an internal screen is a nuisance, not a courtesy.
+     */
+    private static function is_internal($url) {
+        return $url !== '' && strpos($url, admin_url()) === 0;
+    }
+
+    /**
+     * Features still locked, optionally only those at one location.
+     *
+     * @return array<string, array>
+     */
+    public static function locked($location = '') {
+        $locked = [];
+        foreach (self::features() as $id => $feature) {
+            if (($location === '' || $feature['location'] === $location) && !self::unlocked($id)) {
+                $locked[$id] = $feature;
+            }
+        }
+        return $locked;
+    }
+
+    /**
+     * Renders every locked feature registered at a location.
+     */
+    public static function render($location) {
+        $locked = self::locked($location);
+        if (!$locked) {
+            return;
+        }
+        $class = 'calentra-locks calentra-locks--' . $location;
+        echo '<div class="' . esc_attr($class) . '">';
+        foreach (array_keys($locked) as $id) {
+            self::lock($id);
+        }
+        echo '</div>';
+    }
+
+    /**
+     * The locked control for one feature: its real label and a lock, and nothing more
+     * until clicked. Not a form control, so it never submits or appears to hold a value.
+     */
+    public static function lock($id) {
+        $features = self::features();
+        if (!isset($features[$id]) || self::unlocked($id)) {
+            return;
+        }
+        $feature = $features[$id];
+        ?>
+        <details class="calentra-lock" data-calentra-feature="<?php echo esc_attr($id); ?>">
+            <summary><span class="dashicons dashicons-lock" aria-hidden="true"></span><?php echo esc_html($feature['title']); ?><span class="screen-reader-text"> <?php esc_html_e('(locked, part of Navarro Booking Pro)', 'navarro-booking-for-woocommerce'); ?></span></summary>
+            <div class="calentra-lock__panel">
+                <p><?php echo esc_html($feature['blurb']); ?></p>
+                <p><?php esc_html_e('Part of Navarro Booking Pro.', 'navarro-booking-for-woocommerce'); ?>
+                    <?php self::learn_more(); ?></p>
+            </div>
+        </details>
+        <?php
+    }
+
+    /**
+     * The one row at the foot of the settings page. Plain text, not a banner or a box.
+     */
+    public static function settings_footer() {
+        if (!self::locked()) {
+            return;
+        }
+        ?>
+        <p class="calentra-upgrade-foot description" id="<?php echo esc_attr(self::ANCHOR); ?>">
+            <?php esc_html_e('Navarro Booking Pro adds the features marked with a lock: resources, so bookings can be made against staff, rooms or equipment.', 'navarro-booking-for-woocommerce'); ?>
+            <?php self::learn_more(); ?>
+        </p>
+        <?php
+    }
+
+    /** A "learn more" link, or nothing when there is no URL. */
+    private static function learn_more() {
+        $url = self::url();
+        if ($url === '') {
+            return;
+        }
+        if (self::is_internal($url)) {
+            printf(
+                '<a href="%s">%s</a>',
+                esc_url($url),
+                esc_html__('Learn more', 'navarro-booking-for-woocommerce')
+            );
+            return;
+        }
+        printf(
+            '<a href="%s" target="_blank" rel="noopener noreferrer">%s<span class="screen-reader-text"> %s</span></a>',
+            esc_url($url),
+            esc_html__('Learn more', 'navarro-booking-for-woocommerce'),
+            esc_html__('(opens in a new tab)', 'navarro-booking-for-woocommerce')
+        );
+    }
+
+    /**
+     * "Settings", and "Upgrade" while any feature is locked, on the Plugins screen.
+     * Upgrade goes to the URL if there is one, else to the explanation on the settings
+     * page, so it is never a dead link.
+     */
+    public function action_links($links) {
+        if (!current_user_can('manage_woocommerce')) {
+            return $links;
+        }
+        $settings = admin_url('admin.php?page=calentra-settings');
+        $own = ['<a href="' . esc_url($settings) . '">' . esc_html__('Settings', 'navarro-booking-for-woocommerce') . '</a>'];
+        if (self::locked()) {
+            $url   = self::url();
+            $label = esc_html__('Upgrade', 'navarro-booking-for-woocommerce');
+            if ($url === '') {
+                $own[] = '<a href="' . esc_url($settings . '#' . self::ANCHOR) . '">' . $label . '</a>';
+            } elseif (self::is_internal($url)) {
+                $own[] = '<a href="' . esc_url($url) . '">' . $label . '</a>';
+            } else {
+                $own[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . $label . '</a>';
+            }
+        }
+        return array_merge($own, $links);
+    }
+
+    /** The lock styles, on the screens that show locks. */
+    public function enqueue() {
+        $screen = get_current_screen();
+        if (!$screen || !in_array($screen->id, ['product', 'woocommerce_page_calentra-settings', 'woocommerce_page_calentra-bookings'], true)) {
+            return;
+        }
+        wp_enqueue_style('calentra-upgrade', CALENTRA_PLUGIN_URL . 'assets/css/admin-upgrade.css', ['dashicons'], calentra_asset_version('assets/css/admin-upgrade.css'));
+    }
+}
Index: /navarro-booking-for-woocommerce/tags/2.5.0/languages/navarro-booking-for-woocommerce.pot
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/languages/navarro-booking-for-woocommerce.pot	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/languages/navarro-booking-for-woocommerce.pot	(revision 3750436)
@@ -0,0 +1,663 @@
+# Copyright (C) 2026 Dominick Navarro
+# This file is distributed under the GPLv2 or later.
+msgid ""
+msgstr ""
+"Project-Id-Version: Navarro Booking for WooCommerce 2.5.0\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/navarro-booking-for-woocommerce\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"POT-Creation-Date: 2026-10-14T22:32:37+00:00\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"X-Generator: WP-CLI 2.12.0\n"
+"X-Domain: navarro-booking-for-woocommerce\n"
+
+#. Plugin Name of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "Navarro Booking for WooCommerce"
+msgstr ""
+
+#. Plugin URI of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "https://dominicknavarro.com/navarro-booking-pro"
+msgstr ""
+
+#. Description of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "Bookings for WooCommerce: time slots, multi-day rentals and capacity. Seats are never oversold."
+msgstr ""
+
+#. Author of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "Dominick Navarro"
+msgstr ""
+
+#. Author URI of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "https://dominicknavarro.com"
+msgstr ""
+
+#: includes/class-calentra-admin.php:59
+msgid "Navarro Booking Options"
+msgstr ""
+
+#: includes/class-calentra-admin.php:78
+msgid "Enable Booking"
+msgstr ""
+
+#: includes/class-calentra-admin.php:82
+msgid "Hide Quantity Field"
+msgstr ""
+
+#: includes/class-calentra-admin.php:84
+msgid "Hide the quantity selector on the product page for this booking."
+msgstr ""
+
+#: includes/class-calentra-admin.php:88
+msgid "Booking Type"
+msgstr ""
+
+#: includes/class-calentra-admin.php:90
+msgid "Timeslot"
+msgstr ""
+
+#: includes/class-calentra-admin.php:91
+msgid "Multi-Day (priced per night)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:93
+msgid "Time slots within opening hours, or a range of nights from a start date to an end date."
+msgstr ""
+
+#: includes/class-calentra-admin.php:97
+msgid "Start Time (e.g. 09:00)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:99
+msgid "End Time (e.g. 17:00)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:102
+msgid "Booking Duration (minutes)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:105
+#: includes/class-calentra-admin.php:487
+msgid "Bookable up to (days ahead)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:108
+msgid "Leave empty to use the global setting."
+msgstr ""
+
+#: includes/class-calentra-admin.php:109
+msgid "Blocked Dates (YYYY-MM-DD)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:111
+msgid "Blocked Time Slots (YYYY-MM-DD HH:MM)"
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-admin.php:115
+#, php-format
+msgid "Times are in the site timezone (%s)."
+msgstr ""
+
+#. translators: %s: the invalid value
+#: includes/class-calentra-admin.php:153
+#: includes/class-calentra-admin.php:427
+#, php-format
+msgid "\"%s\" is not a valid time (use HH:MM)."
+msgstr ""
+
+#: includes/class-calentra-admin.php:159
+msgid "The end time must be after the start time; no slots will be offered until this is fixed."
+msgstr ""
+
+#. translators: %s: list of rejected entries
+#. translators: %s: rejected entries
+#: includes/class-calentra-admin.php:173
+#: includes/class-calentra-admin.php:389
+#, php-format
+msgid "These blocked dates were not understood and were ignored: %s. Use YYYY-MM-DD."
+msgstr ""
+
+#. translators: %s: list of rejected entries
+#. translators: %s: rejected entries
+#: includes/class-calentra-admin.php:177
+#: includes/class-calentra-admin.php:400
+#, php-format
+msgid "These blocked time slots were not understood and were ignored: %s. Use YYYY-MM-DD HH:MM."
+msgstr ""
+
+#: includes/class-calentra-admin.php:193
+#: includes/class-calentra-admin.php:439
+msgid "Navarro Booking"
+msgstr ""
+
+#: includes/class-calentra-admin.php:218
+#: includes/class-calentra-bookings-list.php:71
+msgid "Booking"
+msgstr ""
+
+#: includes/class-calentra-admin.php:225
+#: includes/class-calentra-display.php:30
+#: includes/class-calentra-frontend.php:88
+msgid "Booking Date"
+msgstr ""
+
+#: includes/class-calentra-admin.php:234
+#: includes/class-calentra-display.php:32
+#: includes/class-calentra-frontend.php:98
+msgid "End Date"
+msgstr ""
+
+#. translators: %s: timezone
+#: includes/class-calentra-admin.php:244
+#, php-format
+msgid "Booking Time (%s)"
+msgstr ""
+
+#. translators: 1: product name, 2: error
+#: includes/class-calentra-admin.php:328
+#, php-format
+msgid "Navarro Booking: booking for %1$s was not changed. %2$s"
+msgstr ""
+
+#. translators: 1: product name, 2: new date and time
+#: includes/class-calentra-admin.php:338
+#, php-format
+msgid "Navarro Booking: booking for %1$s moved to %2$s."
+msgstr ""
+
+#: includes/class-calentra-admin.php:350
+#: includes/class-calentra-admin.php:351
+#: includes/class-calentra-bookings-list.php:295
+msgid "Bookings"
+msgstr ""
+
+#: includes/class-calentra-admin.php:362
+#: includes/class-calentra-admin.php:363
+msgid "Navarro Booking Settings"
+msgstr ""
+
+#: includes/class-calentra-admin.php:441
+#: includes/class-calentra-upgrade.php:210
+msgid "Settings"
+msgstr ""
+
+#: includes/class-calentra-admin.php:442
+msgid "Timezone diagnostics"
+msgstr ""
+
+#: includes/class-calentra-admin.php:464
+msgid "Global Start Time"
+msgstr ""
+
+#: includes/class-calentra-admin.php:468
+msgid "Global End Time"
+msgstr ""
+
+#: includes/class-calentra-admin.php:472
+msgid "Blocked Dates"
+msgstr ""
+
+#: includes/class-calentra-admin.php:474
+msgid "Comma-separated YYYY-MM-DD"
+msgstr ""
+
+#: includes/class-calentra-admin.php:477
+msgid "Blocked Time Slots"
+msgstr ""
+
+#: includes/class-calentra-admin.php:479
+msgid "Comma-separated YYYY-MM-DD HH:MM"
+msgstr ""
+
+#: includes/class-calentra-admin.php:482
+msgid "Cart hold (minutes)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:484
+msgid "How long a slot stays reserved for a customer after they add it to their cart. The hold is refreshed while they are active in the cart or checkout. If it lapses and someone else books the slot, checkout is refused before payment."
+msgstr ""
+
+#: includes/class-calentra-admin.php:489
+msgid "How far ahead customers can book. Products can set their own limit."
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-admin.php:494
+#, php-format
+msgid "All times are in the site timezone (%s), set under Settings → General."
+msgstr ""
+
+#: includes/class-calentra-admin.php:509
+msgid "You do not have permission to do that."
+msgstr ""
+
+#: includes/class-calentra-availability.php:55
+#: includes/class-calentra-availability.php:358
+msgid "This product cannot be booked."
+msgstr ""
+
+#: includes/class-calentra-availability.php:60
+msgid "Invalid date range."
+msgstr ""
+
+#: includes/class-calentra-availability.php:67
+msgid "Date range too long."
+msgstr ""
+
+#: includes/class-calentra-availability.php:361
+msgid "Please choose a valid booking date."
+msgstr ""
+
+#: includes/class-calentra-availability.php:367
+msgid "Please choose an end date after the start date."
+msgstr ""
+
+#. translators: %s: a date
+#: includes/class-calentra-availability.php:378
+#, php-format
+msgid "Sorry, %s is not available for the dates you chose. Please pick different dates."
+msgstr ""
+
+#: includes/class-calentra-availability.php:396
+msgid "Please choose a time slot."
+msgstr ""
+
+#: includes/class-calentra-availability.php:410
+msgid "Sorry, that time slot is not available. Please choose another."
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:72
+#: includes/class-calentra-diagnostics.php:120
+msgid "Product"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:73
+msgid "Resource"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:74
+msgid "Start"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:75
+msgid "End"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:76
+msgid "Seats"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:77
+#: includes/class-calentra-diagnostics.php:122
+msgid "Status"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:78
+#: includes/class-calentra-diagnostics.php:121
+msgid "Order"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:95
+msgid "No bookings match these filters."
+msgstr ""
+
+#. translators: %d: product ID
+#: includes/class-calentra-bookings-list.php:167
+#, php-format
+msgid "Product #%d (deleted)"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:182
+msgid "Filter by product"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:184
+msgid "All products"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:190
+msgid "Filter by status"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:197
+msgid "From"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:199
+msgid "to"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:202
+msgid "Filter"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:209
+msgid "Active (confirmed and live holds)"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:210
+msgid "Confirmed"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:211
+msgid "In a cart (hold)"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:212
+msgid "Cancelled"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:213
+msgid "Expired hold"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:214
+msgid "All statuses"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:259
+msgid "Hold (lapsed)"
+msgstr ""
+
+#. translators: %s: time the hold expires
+#: includes/class-calentra-bookings-list.php:263
+#, php-format
+msgid "In a cart until %s"
+msgstr ""
+
+#. translators: %d: order number
+#: includes/class-calentra-bookings-list.php:276
+#, php-format
+msgid "#%d (deleted)"
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-bookings-list.php:299
+#, php-format
+msgid "Times are shown in the site timezone (%s)."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:475
+msgid "Choose a resource for this booking."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:500
+msgid "Sorry, someone else has just booked that slot. Please choose another."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:511
+msgid "The booking could not be saved. Please try again."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:541
+#: includes/class-calentra-bookings.php:559
+msgid "Your reservation has expired."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:548
+msgid "Sorry, there are not enough places left in that slot."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:610
+msgid "Your reservation could not be found. Please choose your booking again."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:658
+msgid "Sorry, your reservation expired and the slot has since been booked by someone else. You have not been charged. Please choose another time."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:695
+msgid "Booking not found."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:716
+msgid "That slot is already fully booked."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:751
+msgid "This booking is too long to be made on a resource. Please choose a shorter booking."
+msgstr ""
+
+#. translators: 1: product name, 2: error message
+#: includes/class-calentra-cart.php:248
+#, php-format
+msgid "%1$s: your reservation expired and the slot is no longer available. Please remove it from your cart and choose another time. (%2$s)"
+msgstr ""
+
+#. translators: %s: product name
+#: includes/class-calentra-cart.php:366
+#, php-format
+msgid "Navarro Booking: the booking for %s could not be confirmed because the slot was taken. No payment was attempted."
+msgstr ""
+
+#. translators: %s: product name
+#: includes/class-calentra-cart.php:421
+#, php-format
+msgid "Navarro Booking: the booking for %s could not be restored because the slot has since been booked by someone else."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:38
+msgid "Your browser is in a different timezone from the site. Customers in your timezone will see both times next to each slot."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:56
+msgid "Inventory counters rebuilt from bookings."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:59
+msgid "Clocks"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:62
+msgid "Site timezone (Settings → General)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:66
+msgid "This is a fixed UTC offset, not a city. Fixed offsets do not follow daylight saving time, so slot times will be an hour off for part of the year. Choose a city such as \"America/New_York\" instead."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:69
+msgid "Your browser timezone"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:72
+msgid "Server clock (UTC)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:74
+msgid "Database clock (UTC)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:75
+msgid "session time_zone"
+msgstr ""
+
+#. translators: %d: seconds
+#: includes/class-calentra-diagnostics.php:79
+#, php-format
+msgid "The database and web server clocks differ by %d seconds. Hold expiry may be inaccurate."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:82
+msgid "How Navarro Booking stores times"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:83
+msgid "Every booking is stored as a UTC instant together with the site timezone at the time it was made. Customers see slots in the site timezone, with their own local time alongside when it differs."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:87
+msgid "Upcoming daylight-saving changes"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:92
+msgid "None in the next 12 months for this timezone."
+msgstr ""
+
+#. translators: 1: abbreviation, 2: UTC offset in hours
+#: includes/class-calentra-diagnostics.php:98
+#, php-format
+msgid "clocks change to %1$s (UTC%2$+.1f h)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:104
+msgid "Upcoming bookings"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:113
+msgid "No upcoming bookings."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:123
+msgid "Stored (UTC)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:124
+msgid "Site time"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:125
+msgid "Your time"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:126
+msgid "Notes"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:152
+msgid "Inventory"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:153
+msgid "Seat counters are kept alongside bookings so that two customers can never claim the last seat at once. If you have edited the database by hand, rebuild them from the bookings."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:157
+msgid "Rebuild inventory counters"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:175
+msgid "A daylight-saving change falls on this booking's date: double-check the time with the customer."
+msgstr ""
+
+#. translators: %+.1f: hours
+#: includes/class-calentra-diagnostics.php:183
+#, php-format
+msgid "Clocks change before this booking (offset %+.1f h); its UTC time differs from today's by that much. This is expected."
+msgstr ""
+
+#. translators: 1: old timezone, 2: new timezone
+#: includes/class-calentra-diagnostics.php:190
+#, php-format
+msgid "Booked when the site timezone was %1$s; it is now %2$s. The booked instant is unchanged."
+msgstr ""
+
+#: includes/class-calentra-display.php:31
+#: includes/class-calentra-frontend.php:90
+msgid "Time Slot"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:50
+msgid "Loading…"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:51
+msgid "Select a time slot"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:52
+msgid "No available slots"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:53
+msgid "your time"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:54
+msgid "Please select a booking date and time."
+msgstr ""
+
+#: includes/class-calentra-frontend.php:55
+msgid "Please select both start and end dates."
+msgstr ""
+
+#: includes/class-calentra-frontend.php:56
+msgid "Availability could not be loaded. Please refresh the page."
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-frontend.php:58
+#, php-format
+msgid "Times shown in %s"
+msgstr ""
+
+#. translators: %d: number of nights
+#: includes/class-calentra-frontend.php:60
+#, php-format
+msgid "%d night"
+msgid_plural "%d nights"
+msgstr[0] ""
+msgstr[1] ""
+
+#: includes/class-calentra-frontend.php:72
+msgid "Book Now"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:92
+msgid "Select a date first"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:96
+msgid "Start Date"
+msgstr ""
+
+#. translators: 1: site name, 2: product name
+#: includes/class-calentra-ics.php:90
+#, php-format
+msgid "Booking from %1$s for %2$s"
+msgstr ""
+
+#: includes/class-calentra-rest.php:43
+msgid "Product not found."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:60
+msgid "Resources"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:61
+msgid "Book staff, rooms or equipment, chosen by the customer or assigned to whichever is free."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:153
+msgid "(locked, part of Navarro Booking Pro)"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:156
+msgid "Part of Navarro Booking Pro."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:172
+msgid "Navarro Booking Pro adds the features marked with a lock: resources, so bookings can be made against staff, rooms or equipment."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:188
+#: includes/class-calentra-upgrade.php:195
+msgid "Learn more"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:196
+msgid "(opens in a new tab)"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:213
+msgid "Upgrade"
+msgstr ""
Index: /navarro-booking-for-woocommerce/tags/2.5.0/navarro-booking-for-woocommerce.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/navarro-booking-for-woocommerce.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/navarro-booking-for-woocommerce.php	(revision 3750436)
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Plugin Name:       Navarro Booking for WooCommerce
+ * Plugin URI:        https://dominicknavarro.com/navarro-booking-pro
+ * Description:       Bookings for WooCommerce: time slots, multi-day rentals and capacity. Seats are never oversold.
+ * Version:           2.5.0
+ * Requires at least: 6.5
+ * Requires PHP:      7.4
+ * Requires Plugins:  woocommerce
+ * Author:            Dominick Navarro
+ * Author URI:        https://dominicknavarro.com
+ * License:           GPLv2 or later
+ * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
+ * Text Domain:       navarro-booking-for-woocommerce
+ * Domain Path:       /languages
+ * WC requires at least: 8.0
+ *
+ * Navarro Booking for WooCommerce
+ * Copyright (C) 2025-2026 Dominick Navarro
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 2 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ */
+
+defined('ABSPATH') || exit;
+
+define('CALENTRA_VERSION', '2.5.0');
+define('CALENTRA_PLUGIN_FILE', __FILE__);
+define('CALENTRA_PLUGIN_PATH', plugin_dir_path(__FILE__));
+define('CALENTRA_PLUGIN_URL', plugin_dir_url(__FILE__));
+
+/**
+ * Version string for a plugin asset, from its modification time. Scripts used to be
+ * enqueued with no version, so browsers and Cloudflare kept serving stale copies
+ * (up to 30 days) after the files changed.
+ */
+function calentra_asset_version($relative_path) {
+    $file = CALENTRA_PLUGIN_PATH . $relative_path;
+    return file_exists($file) ? (string) filemtime($file) : CALENTRA_VERSION;
+}
+
+require_once CALENTRA_PLUGIN_PATH . 'includes/class-calentra-install.php';
+require_once CALENTRA_PLUGIN_PATH . 'includes/class-calentra-loader.php';
+
+register_activation_hook(__FILE__, ['Calentra_Install', 'install']);
+register_deactivation_hook(__FILE__, function () {
+    wp_clear_scheduled_hook('calentra_sweep');
+});
+
+// Bookings live in a custom table, so the order storage engine doesn't matter to us.
+add_action('before_woocommerce_init', function () {
+    if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('cart_checkout_blocks', __FILE__, true);
+    }
+});
+
+add_action('plugins_loaded', function () {
+    if (!class_exists('WooCommerce')) {
+        return;
+    }
+    Calentra_Loader::includes();
+    (new Calentra_Loader())->run();
+});
+
+// On init rather than plugins_loaded: the migration reads orders, and WooCommerce's
+// order data stores are not ready until partway through init.
+add_action('init', function () {
+    if (class_exists('WooCommerce')) {
+        Calentra_Install::maybe_upgrade();
+    }
+}, 20);
Index: /navarro-booking-for-woocommerce/tags/2.5.0/readme.txt
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/readme.txt	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/readme.txt	(revision 3750436)
@@ -0,0 +1,175 @@
+=== Navarro Booking for WooCommerce ===
+Contributors: shirofuji00
+Tags: booking, appointments, woocommerce, calendar, reservations
+Requires at least: 6.5
+Tested up to: 7.1
+Requires PHP: 7.4
+Requires Plugins: woocommerce
+Stable tag: 2.5.0
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+Time-slot and multi-day bookings for WooCommerce: real capacity, a cache-safe calendar and timezone-correct times. Seats are never oversold.
+
+== Description ==
+
+Navarro Booking turns any WooCommerce product into something customers can book: a class, an appointment, a room for a night. Customers pick a date and a time slot (or a start and end date) on the product page, and the booking goes through the normal WooCommerce cart and checkout.
+
+It is built around three problems booking plugins are known for.
+
+**Never oversold.** A slot is reserved the moment a customer adds it to the cart, with a single atomic database operation, so two customers can never both take the last place. The reservation is checked again when the order is placed; if it has lapsed and someone else has taken the slot, the order is refused before any payment is taken. The plugin ships with a public concurrency test suite that races many customers for one slot and checks that exactly the right number succeed.
+
+**Works with page caching.** Availability is loaded from a public, read-only REST endpoint with no nonce, so a product page served from WP Rocket, LiteSpeed Cache or Cloudflare keeps working. There is nothing to exclude from your cache.
+
+**Times are right.** Bookings are stored as exact UTC instants together with your site timezone. Customers see each slot in your timezone, labelled, with their own local time alongside when it differs. A diagnostics screen shows your site, server, database and browser clocks, upcoming daylight-saving changes, and how each upcoming booking is stored.
+
+= Features =
+
+* Time-slot bookings within opening hours, or multi-day bookings priced per night
+* Per-product settings layered over global defaults
+* Block whole dates or individual time slots, per product or site-wide
+* Cart reservations that don't depend on WP-Cron
+* Works with the block cart and checkout and the classic shortcodes
+* `.ics` calendar invite attached to order emails
+* Edit a booking's date and time from the order screen (capacity is still enforced)
+* Bookings released automatically when orders are cancelled, failed, refunded or deleted
+* Timezone diagnostics screen
+* Compatible with WooCommerce High-Performance Order Storage
+* Documented actions and filters for developers (see `docs/extending.md`)
+* Translation-ready
+
+= Navarro Booking Pro =
+
+Navarro Booking Pro is a separate, paid plugin that adds resources: book staff, rooms or equipment, chosen by the customer or assigned to whichever is free. A resource shared by several products is never booked twice for the same time, with the same atomic guarantee as the rest of Navarro Booking.
+
+This plugin is complete without it. Nothing here is time-limited, and nothing stops working if you never buy Pro. Where resources would appear in a product's booking options, a lock explains them when clicked; there are no notices or banners.
+
+[Navarro Booking Pro](https://dominicknavarro.com/navarro-booking-pro)
+
+== Installation ==
+
+1. Install and activate WooCommerce.
+2. Upload the plugin to `/wp-content/plugins/navarro-booking-for-woocommerce/`, or install it from the Plugins screen, and activate it.
+3. Under **Settings → General**, make sure your timezone is a city (for example "America/New_York"), not a fixed UTC offset.
+4. Visit **WooCommerce → Navarro Booking Settings** for global opening hours and blocked dates.
+5. Edit a product and tick **Enable Booking** in the **Navarro Booking Options** box.
+
+== Frequently Asked Questions ==
+
+= Do I need to exclude product pages from my page cache? =
+
+No. The booking calendar loads availability from a public REST endpoint that doesn't use a nonce, so cached pages keep working.
+
+= How long is a slot held while a customer is in checkout? =
+
+60 minutes by default, changeable under WooCommerce → Navarro Booking Settings. The hold is refreshed while the customer is active in the cart or checkout. If it lapses, the slot is taken again at checkout if it is still free; if someone else has booked it, checkout is refused before payment.
+
+= What happens to multi-day end dates? =
+
+The end date is the departure date. A booking from the 3rd to the 6th covers the nights of the 3rd, 4th and 5th and is priced as three nights; someone else can start a booking on the 6th.
+
+= Can more than one customer book the same slot? =
+
+Each slot holds one booking by default. Developers can raise the number of places with the `calentra_slot_capacity` filter; everything else, including the concurrency guarantees, works with any capacity.
+
+= Does it sync with Google Calendar? =
+
+Every order email carries an `.ics` invite that any calendar app can import. There is no two-way sync.
+
+= I'm upgrading from 1.x. What happens to my bookings? =
+
+They are migrated on the first page load after the update. Version 1.1.0 had a bug that stopped bookings reaching its bookings table; the migration recovers those from your orders, so slots already sold stay blocked. The old table is kept as a backup until you delete the plugin.
+
+= Is there a paid version? =
+
+Yes, [Navarro Booking Pro](https://dominicknavarro.com/navarro-booking-pro), a separate plugin that adds bookable staff, rooms and equipment. Everything described above is in this free plugin and stays free.
+
+== Screenshots ==
+
+1. Choosing a time slot on the product page, with the timezone shown.
+2. Booking options on a product.
+3. Global settings.
+4. Timezone diagnostics.
+
+== Changelog ==
+
+= 2.5.0 =
+* Change: renamed from Calentra Booking for WooCommerce to Navarro Booking for WooCommerce. Settings, bookings and developer hooks are unchanged.
+
+= 2.4.1 =
+* Change: the bundled Freemius SDK is removed. This plugin contains no licensing, update or telemetry code. "Learn more" and "Upgrade" now link to the Navarro Booking Pro page.
+* Dev: the timezone diagnostics screen now loads its script and styles as enqueued assets rather than printing them inline.
+* Dev: compiled translations are no longer bundled. Translations are served through translate.wordpress.org instead.
+
+= 2.4.0 =
+* Fix: a resource offered by several products (a teacher, a room) is now counted once across all of them. Before, each product counted it separately, so two products could both sell the last place on it for the same time. Bookings that overlap are caught even when the products' slots start at different times.
+* Fix: "Rebuild inventory" on the diagnostics screen no longer undercounts a slot that has a lapsed cart hold.
+* Dev: `calentra_slot_capacity` is also asked with product 0 for a resource's whole capacity. A booking on a resource may span at most about 33 hours (`calentra_too_long`).
+* Fix: the Booking Options panel now opens in the main column instead of the narrow side column, where its times, blocked dates and resource rows were squeezed. If you have already dragged it somewhere, WordPress keeps your own arrangement.
+* The update counts existing bookings on resources once, on the first request after updating. For a perfectly clean update, put the shop in maintenance mode or run the update with WP-CLI before customers reach it; otherwise use "Rebuild inventory" afterwards.
+
+= 2.3.0 =
+* New: Navarro Booking Pro's resources are shown where they would be in the product's booking options, with a lock. The lock explains itself only when clicked and is not shown once Pro is active. No notices, no dashboard widget, and nothing on activation.
+* New: "Settings" and "Upgrade" links on the Plugins screen.
+* Dev: `calentra_feature_unlocked` says whether a feature is available, and `calentra_upgrade_url` sets where "learn more" goes. It is empty by default, and then no link is shown.
+
+= 2.2.0 =
+* Dev: `calentra_claim_resources` lets an extension pool resources. Core tries each candidate with the same atomic claim in turn, and the first with room wins, so "any free member of staff" or "any free room" never needs a read-then-write.
+* Dev: the cart remembers which resource a hold was actually claimed on, so the customer's own hold is found again instead of a second seat being claimed.
+* Dev: the date picker sends `resource_id` when the product form has a `calentra_resource_id` field, and reloads availability when it changes.
+* Dev: `calentra_ics_summary` filters the title of the `.ics` invite.
+* Dev: when `calentra_claim_resources` offers no resource at all, the claim fails with "Choose a resource for this booking" instead of claiming the slot was just taken, and `calentra_claim_failed` does not fire.
+* No change in behaviour without an extension using these.
+
+= 2.1.0 =
+* New: WooCommerce → Bookings lists bookings with their product, resource, start and end in the site timezone, seats, status and order. Filter by product, status and date range.
+* New: how far ahead customers can book is now a setting, with a global default (365 days) and an optional per-product limit. It was fixed at 365 days.
+* New: the availability endpoint accepts an optional `resource_id` and returns it in every response, so extensions that book against resources can ask about one resource at a time. Responses without it are unchanged apart from the added `"resource_id": 0`.
+* Dev: `calentra_resource_label` filter names resources in the bookings list.
+
+= 2.0.0 =
+* New: slots are reserved atomically when added to the cart, and confirmed atomically at checkout. A slot can no longer sell more seats than it has, including under heavy concurrent traffic.
+* New: the date picker loads availability from a public REST endpoint, so it works on cached pages.
+* New: bookings are stored as UTC instants with the site timezone; slots are labelled with their timezone and the customer's local time.
+* New: timezone diagnostics screen.
+* New: cart hold duration setting.
+* New: `.ics` invites built from the stored UTC time, with a stable UID; temporary files are removed after sending.
+* New: actions and filters for extensions, documented in `docs/extending.md`.
+* Fix: requested dates and times were not checked when added to the cart, so blocked, past or out-of-hours slots could be bought.
+* Fix: bookings from 1.1.0 were never saved to the bookings table, and `.ics` invites were never attached. Existing orders are migrated.
+* Fix: global blocked dates and blocked time slots could not be saved.
+* Fix: booking options are saved only for products, with nonce and capability checks. 1.x wrote empty booking settings onto every page and post you saved; these are removed.
+* Fix: invalid blocked dates are reported instead of silently ignored.
+* Fix: multi-day availability no longer loads every order on every request.
+* Fix: "Hide Quantity Field" now applies on the shop front end.
+* Fix: multi-day price display no longer assumes the Philippine peso.
+
+= 1.1.0 =
+* Multi-day bookings.
+
+= 1.0.0 =
+* Initial release.
+
+== Upgrade Notice ==
+
+= 2.5.0 =
+New name, same plugin. No database changes.
+
+= 2.4.0 =
+Fixes double selling of a resource shared by several products. Adds a database index and counts existing resource bookings on the first request after updating.
+
+= 2.3.0 =
+Shows Pro's resources, locked, in the product's booking options, explained only when clicked. No database changes.
+
+= 2.2.0 =
+Developer hooks for booking against pools of resources. No change for existing shops, and no database changes.
+
+= 2.1.0 =
+Adds a Bookings screen under WooCommerce and a setting for how far ahead customers can book. No database changes.
+
+= 2.0.0 =
+Prevents oversold slots and fixes bookings not being recorded in 1.1.0. Back up your database before updating; existing bookings are migrated automatically.
+
+== Third-Party Libraries ==
+
+* Flatpickr 4 (MIT License) — https://flatpickr.js.org/
Index: /navarro-booking-for-woocommerce/tags/2.5.0/uninstall.php
===================================================================
--- /navarro-booking-for-woocommerce/tags/2.5.0/uninstall.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/tags/2.5.0/uninstall.php	(revision 3750436)
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Removes Navarro Booking's settings when the plugin is deleted.
+ *
+ * Booking records are kept: they are the history of orders customers paid for, and
+ * deleting a plugin should not silently erase them. Define CALENTRA_REMOVE_ALL_DATA as
+ * true in wp-config.php before deleting the plugin to drop the tables as well.
+ */
+if (!defined('WP_UNINSTALL_PLUGIN')) {
+    exit;
+}
+
+foreach ([
+    'calentra_global_start',
+    'calentra_global_end',
+    'calentra_global_blocked_dates',
+    'calentra_global_blocked_slots',
+    'calentra_hold_minutes',
+    'calentra_max_days_ahead',
+    'calentra_global_cache_version',
+    'calentra_upgrade_lock',
+    'calentra_upgrade_url',
+] as $calentra_option) {
+    delete_option($calentra_option);
+}
+wp_clear_scheduled_hook('calentra_sweep');
+
+global $wpdb;
+// The v1.1.0 table kept as a backup by the 2.0 migration.
+$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}calentra_bookings_v1"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- dropping Navarro Booking's own tables on uninstall.
+$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}calentra_slot_inventory"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- rebuilt from bookings on reinstall.
+
+if (defined('CALENTRA_REMOVE_ALL_DATA') && CALENTRA_REMOVE_ALL_DATA) {
+    $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}calentra_bookings"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- dropping Navarro Booking's own tables on uninstall.
+    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk meta removal on uninstall; no core API deletes by key pattern.
+    $wpdb->query("DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_calentra\\_%'");
+    delete_option('calentra_db_version');
+    delete_option('calentra_v1_migrated');
+}
Index: /navarro-booking-for-woocommerce/trunk/LICENSE
===================================================================
--- /navarro-booking-for-woocommerce/trunk/LICENSE	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/LICENSE	(revision 3750436)
@@ -0,0 +1,359 @@
+Navarro Booking for WooCommerce
+Copyright (C) 2025-2026 Dominick Navarro
+
+This program is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation; either version 2 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
+Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Flatpickr is bundled under the MIT License and remains under its own terms.
+See readme.txt, Third-Party Libraries.
+
+----------------------------------------------------------------------
+
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Moe Ghoul>, 1 April 1989
+  Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
Index: /navarro-booking-for-woocommerce/trunk/assets/css/admin-diagnostics.css
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/css/admin-diagnostics.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/css/admin-diagnostics.css	(revision 3750436)
@@ -0,0 +1,14 @@
+/* Timezone diagnostics tab. */
+.calentra-warn {
+    color: #b32d2e;
+    margin: .3em 0;
+}
+
+.calentra-diag th {
+    width: auto;
+}
+
+/* Each cell carries the start time and the end time on separate lines. */
+.calentra-viewer-time {
+    white-space: pre-line;
+}
Index: /navarro-booking-for-woocommerce/trunk/assets/css/admin-upgrade.css
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/css/admin-upgrade.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/css/admin-upgrade.css	(revision 3750436)
@@ -0,0 +1,64 @@
+/* Locked Pro features (Calentra_Upgrade). Muted, never styled as an input. */
+.calentra-locks {
+    margin: 8px 0;
+}
+.calentra-lock {
+    margin: 4px 0;
+    color: #646970;
+}
+.calentra-lock > summary {
+    cursor: pointer;
+    list-style: none;
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+}
+.calentra-lock > summary::-webkit-details-marker {
+    display: none;
+}
+.calentra-lock > summary .dashicons {
+    font-size: 16px;
+    width: 16px;
+    height: 16px;
+}
+.calentra-lock > summary:hover,
+.calentra-lock[open] > summary {
+    color: #1d2327;
+}
+.calentra-lock__panel {
+    margin: 4px 0 0 18px;
+    max-width: 40em;
+}
+.calentra-lock__panel p {
+    margin: 0 0 4px;
+}
+
+/* WooCommerce → Bookings: inline beside the page title, panel floats over the page. */
+.calentra-locks--bookings_actions {
+    display: inline-flex;
+    flex-wrap: wrap;
+    gap: 12px;
+    margin: 0 0 0 8px;
+    vertical-align: middle;
+}
+.calentra-locks--bookings_actions .calentra-lock {
+    position: relative;
+    margin: 0;
+}
+.calentra-locks--bookings_actions .calentra-lock__panel {
+    position: absolute;
+    z-index: 10;
+    top: 100%;
+    left: 0;
+    width: 22em;
+    margin: 4px 0 0;
+    padding: 8px 12px;
+    background: #fff;
+    border: 1px solid #c3c4c7;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+    color: #1d2327;
+}
+
+.calentra-upgrade-foot {
+    margin-top: 2em;
+}
Index: /navarro-booking-for-woocommerce/trunk/assets/css/flatpickr.min.css
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/css/flatpickr.min.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/css/flatpickr.min.css	(revision 3750436)
@@ -0,0 +1,13 @@
+.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/*
+      /*rtl:begin:ignore*/left:0/*
+      /*rtl:end:ignore*/}/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/*
+      /*rtl:begin:ignore*/right:0/*
+      /*rtl:end:ignore*/}/*
+      /*rtl:begin:ignore*/
+/*
+      /*rtl:end:ignore*/
+.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}
Index: /navarro-booking-for-woocommerce/trunk/assets/css/style.css
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/css/style.css	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/css/style.css	(revision 3750436)
@@ -0,0 +1,53 @@
+/* Navarro Booking Plugin Styles */
+.calentra-booking {
+    padding: 10px;
+    border-left: 4px solid #F5B942;
+}
+
+.calentra-booking-ui {
+    padding: 10px;
+    border-left: 4px solid #F5B942;
+    background: #fefefe;
+    margin-bottom: 1rem;
+}
+
+.calentra-booking-ui label {
+    font-weight: 600;
+}
+
+/* Make date and time fields uniform */
+.calentra-booking-ui input[type="text"],
+.calentra-booking-ui select {
+    height: 42px;
+    width: 100%;
+    padding: 6px 10px;
+    font-size: 14px;
+    box-sizing: border-box;
+    margin-top: 4px;
+}
+
+.calentra-booking-fields {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    margin-bottom: 20px;
+    border-left: 4px solid #F5B942;
+    padding-left: 10px;
+}
+
+.calentra-booking-fields label {
+    font-weight: 600;
+    font-size: 14px;
+    margin-bottom: 4px;
+}
+
+.calentra-booking-fields input[type="text"],
+.calentra-booking-fields select {
+    padding: 10px 12px;
+    font-size: 15px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    width: 100%;
+    box-sizing: border-box;
+}
+
Index: /navarro-booking-for-woocommerce/trunk/assets/js/admin-diagnostics.js
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/js/admin-diagnostics.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/js/admin-diagnostics.js	(revision 3750436)
@@ -0,0 +1,40 @@
+/**
+ * Timezone diagnostics: fills in what *this browser* thinks the time is, so the
+ * viewer's clock can be compared against the site's, the server's and the database's.
+ *
+ * Everything here is client-side by necessity — the viewer's timezone is not something
+ * the server can know. Settings come from wp_localize_script as calentraDiagnostics.
+ */
+(function () {
+    'use strict';
+
+    var settings = window.calentraDiagnostics || {};
+    var viewerTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+    var format = new Intl.DateTimeFormat(undefined, {
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+        hour: '2-digit',
+        minute: '2-digit',
+        timeZoneName: 'short'
+    });
+
+    function setText(id, text) {
+        var el = document.getElementById(id);
+        if (el) {
+            el.textContent = text;
+        }
+    }
+
+    setText('calentra-viewer-tz', viewerTz);
+    setText('calentra-viewer-now', format.format(new Date()));
+
+    if (settings.siteTimezone && viewerTz !== settings.siteTimezone) {
+        setText('calentra-viewer-note', settings.differentTimezoneNote || '');
+    }
+
+    document.querySelectorAll('.calentra-viewer-time').forEach(function (cell) {
+        cell.textContent = format.format(new Date(cell.dataset.start)) + '\n' +
+            format.format(new Date(cell.dataset.end));
+    });
+})();
Index: /navarro-booking-for-woocommerce/trunk/assets/js/admin-ui.js
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/js/admin-ui.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/js/admin-ui.js	(revision 3750436)
@@ -0,0 +1,25 @@
+/* global flatpickr */
+jQuery(function ($) {
+    // Inputs stay free-text (allowInput) so values can be pasted; the server parses and
+    // reports anything it can't read.
+    $('.calentra-blocked-dates').each(function () {
+        flatpickr(this, { mode: 'multiple', dateFormat: 'Y-m-d', conjunction: ', ', allowInput: true });
+    });
+
+    $('.calentra-blocked-slots').each(function () {
+        flatpickr(this, { mode: 'multiple', enableTime: true, dateFormat: 'Y-m-d H:i', conjunction: ', ', time_24hr: true, allowInput: true });
+    });
+
+    function initOrderPickers(root) {
+        $(root).find('.calentra-flatpickr-date').each(function () {
+            if (!this._flatpickr) flatpickr(this, { dateFormat: 'Y-m-d', allowInput: true });
+        });
+        $(root).find('.calentra-flatpickr-time').each(function () {
+            if (!this._flatpickr) flatpickr(this, { enableTime: true, noCalendar: true, dateFormat: 'H:i', time_24hr: true, allowInput: true });
+        });
+    }
+    initOrderPickers(document);
+    // The order items table is re-rendered by AJAX after saves and edits.
+    $(document.body).on('wc_order_items_reloaded items_saved', () => initOrderPickers(document));
+    $('#woocommerce-order-items').on('click', '.edit-order-item', () => setTimeout(() => initOrderPickers(document), 0));
+});
Index: /navarro-booking-for-woocommerce/trunk/assets/js/booking.js
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/js/booking.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/js/booking.js	(revision 3750436)
@@ -0,0 +1,228 @@
+/* global flatpickr, calentra_booking */
+jQuery(function ($) {
+    const $dateInput = $('#calentra_booking_date');
+
+    // Nothing to wire up if this page has no booking field.
+    if (!$dateInput.length || typeof calentra_booking === 'undefined') {
+        return;
+    }
+
+    const cfg = calentra_booking;
+    const t = cfg.i18n;
+    const siteTz = cfg.site_timezone;
+    const viewerTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+    const $timeSelect = $('#calentra_booking_time');
+    const $endInput = $('#calentra_booking_end_date');
+    const $error = $('#calentra_error');
+    // An extension may add a resource choice (staff, room) to the form. It is static
+    // markup, so cached pages keep working; availability is asked for per resource.
+    const $resource = $dateInput.closest('form').find('[name="calentra_resource_id"]');
+    const resourceId = () => ($resource.length ? String($resource.val() || '0') : '');
+
+    // Calendar dates are always the site's calendar dates, as "YYYY-MM-DD".
+    const ymd = (date) => date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0');
+    const addDays = (str, n) => {
+        const d = new Date(str + 'T12:00:00');
+        d.setDate(d.getDate() + n);
+        return ymd(d);
+    };
+    const siteToday = () => new Intl.DateTimeFormat('en-CA', { timeZone: siteTz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
+
+    function showError(message) {
+        $error.text(message || '').prop('hidden', !message);
+    }
+
+    // ── Availability, fetched by range and kept per date ───────────────────────
+    const known = {}; // date -> slots array (timeslot) or seats number (multi-day)
+    const loaded = {}; // range key -> promise
+
+    function load(from) {
+        const to = addDays(from, 41); // a six-week calendar page
+        const resource = resourceId();
+        const key = resource + '|' + from + '|' + to;
+        if (!loaded[key]) {
+            const params = { product_id: cfg.product_id, from: from, to: to };
+            if (resource !== '') params.resource_id = resource;
+            const url = cfg.rest_url + (cfg.rest_url.indexOf('?') === -1 ? '?' : '&') + $.param(params);
+            // No nonce: the endpoint is public, so a cached page can't go stale on it.
+            loaded[key] = fetch(url, { credentials: 'same-origin' })
+                .then((r) => {
+                    if (!r.ok) throw new Error(r.status);
+                    return r.json();
+                })
+                .then((data) => {
+                    // Drop an answer that arrives after the customer changed resource.
+                    if (resource === resourceId()) {
+                        Object.assign(known, data.days || data.nights || {});
+                    }
+                    return data;
+                })
+                .catch((e) => {
+                    delete loaded[key];
+                    showError(t.load_error);
+                    throw e;
+                });
+        }
+        return loaded[key];
+    }
+
+    function visibleFrom(instance) {
+        // The calendar page shows up to a week of the previous month.
+        const from = addDays(ymd(new Date(instance.currentYear, instance.currentMonth, 1)), -7);
+        const today = siteToday();
+        return from < today ? today : from;
+    }
+
+    const maxDate = addDays(siteToday(), cfg.max_days);
+
+    // ── Time-zone aware slot labels ───────────────────────────────────────────
+    function zoneName(date, timeZone) {
+        const part = new Intl.DateTimeFormat(undefined, { timeZone: timeZone, timeZoneName: 'short' })
+            .formatToParts(date).find((p) => p.type === 'timeZoneName');
+        return part ? part.value : timeZone;
+    }
+    function clock(date, timeZone) {
+        return new Intl.DateTimeFormat(undefined, { timeZone: timeZone, hour: '2-digit', minute: '2-digit' }).format(date);
+    }
+    function dayOf(date, timeZone) {
+        return new Intl.DateTimeFormat('en-CA', { timeZone: timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
+    }
+    function slotLabel(slot) {
+        const start = new Date(slot.start);
+        let label = clock(start, siteTz) + ' ' + zoneName(start, siteTz);
+        if (viewerTz && viewerTz !== siteTz && zoneName(start, viewerTz) !== zoneName(start, siteTz)) {
+            label += ' · ' + clock(start, viewerTz) + ' ' + zoneName(start, viewerTz) + ' (' + t.your_time + ')';
+            if (dayOf(start, viewerTz) !== dayOf(start, siteTz)) {
+                label += ' ' + new Intl.DateTimeFormat(undefined, { timeZone: viewerTz, weekday: 'short', day: 'numeric', month: 'short' }).format(start);
+            }
+        }
+        return label;
+    }
+
+    // ── Timeslot products ─────────────────────────────────────────────────────
+    function fillSlots(dateStr) {
+        const slots = known[dateStr] || [];
+        $timeSelect.empty();
+        if (!slots.length) {
+            $timeSelect.append($('<option>', { value: '', text: t.no_slots, disabled: true, selected: true }));
+            return;
+        }
+        $timeSelect.append($('<option>', { value: '', text: t.select_slot }));
+        slots.forEach((slot) => {
+            $timeSelect.append($('<option>', { value: slot.local, text: slotLabel(slot) }));
+        });
+        const sample = new Date(slots[0].start);
+        $('#calentra_tz_note').text(t.times_in.replace('%s', siteTz + ' (' + zoneName(sample, siteTz) + ')'));
+    }
+
+    const pickers = [];
+    const refresh = (instance) => load(visibleFrom(instance)).then(() => instance.redraw()).catch(() => {});
+
+    function initTimeslot() {
+        const picker = flatpickr($dateInput[0], {
+            minDate: siteToday(),
+            maxDate: maxDate,
+            dateFormat: 'Y-m-d',
+            disable: [(date) => {
+                const key = ymd(date);
+                return key in known && known[key].length === 0;
+            }],
+            onReady: (sel, str, instance) => refresh(instance),
+            onMonthChange: (sel, str, instance) => refresh(instance),
+            onYearChange: (sel, str, instance) => refresh(instance),
+            onChange: (selectedDates, dateStr) => {
+                showError('');
+                if (!dateStr) return;
+                $timeSelect.html($('<option>', { value: '', text: t.loading }));
+                load(dateStr).then(() => fillSlots(dateStr)).catch(() => {});
+            },
+        });
+        pickers.push(picker);
+    }
+
+    // ── Multi-day products: nights from start date up to (not including) end date ──
+    function initMultiDay() {
+        const free = (key) => !(key in known) || known[key] > 0;
+
+        // The end date is the departure date: it may be a booked night, but every
+        // night between start and end must be free.
+        const endPicker = flatpickr($endInput[0], {
+            dateFormat: 'Y-m-d',
+            minDate: addDays(siteToday(), 1),
+            maxDate: addDays(maxDate, 1),
+            disable: [(date) => {
+                const start = $dateInput.val();
+                const key = ymd(date);
+                if (!start || key <= start) return true;
+                for (let d = start; d < key; d = addDays(d, 1)) {
+                    if (!free(d)) return true;
+                }
+                return false;
+            }],
+            onReady: (sel, str, instance) => refresh(instance),
+            onMonthChange: (sel, str, instance) => refresh(instance),
+            onChange: () => {
+                showError('');
+                updateNights();
+            },
+        });
+
+        const startPicker = flatpickr($dateInput[0], {
+            minDate: siteToday(),
+            maxDate: maxDate,
+            dateFormat: 'Y-m-d',
+            disable: [(date) => !free(ymd(date))],
+            onReady: (sel, str, instance) => refresh(instance),
+            onMonthChange: (sel, str, instance) => refresh(instance),
+            onChange: (selectedDates, dateStr) => {
+                showError('');
+                if (!dateStr) return;
+                const end = $endInput.val();
+                endPicker.set('minDate', addDays(dateStr, 1));
+                if (end && end <= dateStr) endPicker.clear();
+                load(dateStr).then(() => endPicker.redraw()).catch(() => {});
+                updateNights();
+            },
+        });
+        pickers.push(startPicker, endPicker);
+    }
+
+    function updateNights() {
+        const start = $dateInput.val();
+        const end = $endInput.val();
+        if (!start || !end || end <= start) {
+            $('#calentra_nights').text('');
+            return;
+        }
+        const nights = Math.round((new Date(end + 'T12:00:00') - new Date(start + 'T12:00:00')) / 86400000);
+        const form = nights === 1 ? t.nights.singular : t.nights.plural;
+        $('#calentra_nights').text(form.replace('%d', nights));
+    }
+
+    if (cfg.type === 'multi-day') {
+        initMultiDay();
+    } else {
+        initTimeslot();
+    }
+
+    // A different resource has different availability: forget what was loaded, clear
+    // choices that may not exist for it, and ask again.
+    $resource.on('change', () => {
+        Object.keys(known).forEach((k) => delete known[k]);
+        Object.keys(loaded).forEach((k) => delete loaded[k]);
+        showError('');
+        pickers.forEach((p) => p.clear());
+        $timeSelect.html($('<option>', { value: '', text: t.select_slot }));
+        updateNights();
+        pickers.forEach(refresh);
+    });
+
+    // Catch an incomplete choice before the round trip; the server validates regardless.
+    $('form.cart').on('submit', function (e) {
+        const date = $dateInput.val();
+        if (cfg.type === 'multi-day' ? !date || !$endInput.val() : !date || !$timeSelect.val()) {
+            showError(cfg.type === 'multi-day' ? t.pick_dates : t.pick_date_time);
+            e.preventDefault();
+        }
+    });
+});
Index: /navarro-booking-for-woocommerce/trunk/assets/js/flatpickr.min.js
===================================================================
--- /navarro-booking-for-woocommerce/trunk/assets/js/flatpickr.min.js	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/assets/js/flatpickr.min.js	(revision 3750436)
@@ -0,0 +1,2 @@
+/* flatpickr v4.6.13,, @license MIT */
+!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e="undefined"!=typeof globalThis?globalThis:e||self).flatpickr=n()}(this,(function(){"use strict";var e=function(){return(e=Object.assign||function(e){for(var n,t=1,a=arguments.length;t<a;t++)for(var i in n=arguments[t])Object.prototype.hasOwnProperty.call(n,i)&&(e[i]=n[i]);return e}).apply(this,arguments)};function n(){for(var e=0,n=0,t=arguments.length;n<t;n++)e+=arguments[n].length;var a=Array(e),i=0;for(n=0;n<t;n++)for(var o=arguments[n],r=0,l=o.length;r<l;r++,i++)a[i]=o[r];return a}var t=["onChange","onClose","onDayCreate","onDestroy","onKeyDown","onMonthChange","onOpen","onParseConfig","onReady","onValueUpdate","onYearChange","onPreCalendarPosition"],a={_disable:[],allowInput:!1,allowInvalidPreload:!1,altFormat:"F j, Y",altInput:!1,altInputClass:"form-control input",animate:"object"==typeof window&&-1===window.navigator.userAgent.indexOf("MSIE"),ariaDateFormat:"F j, Y",autoFillDefaultTime:!0,clickOpens:!0,closeOnSelect:!0,conjunction:", ",dateFormat:"Y-m-d",defaultHour:12,defaultMinute:0,defaultSeconds:0,disable:[],disableMobile:!1,enableSeconds:!1,enableTime:!1,errorHandler:function(e){return"undefined"!=typeof console&&console.warn(e)},getWeek:function(e){var n=new Date(e.getTime());n.setHours(0,0,0,0),n.setDate(n.getDate()+3-(n.getDay()+6)%7);var t=new Date(n.getFullYear(),0,4);return 1+Math.round(((n.getTime()-t.getTime())/864e5-3+(t.getDay()+6)%7)/7)},hourIncrement:1,ignoredFocusElements:[],inline:!1,locale:"default",minuteIncrement:5,mode:"single",monthSelectorType:"dropdown",nextArrow:"<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 17 17'><g></g><path d='M13.207 8.472l-7.854 7.854-0.707-0.707 7.146-7.146-7.146-7.148 0.707-0.707 7.854 7.854z' /></svg>",noCalendar:!1,now:new Date,onChange:[],onClose:[],onDayCreate:[],onDestroy:[],onKeyDown:[],onMonthChange:[],onOpen:[],onParseConfig:[],onReady:[],onValueUpdate:[],onYearChange:[],onPreCalendarPosition:[],plugins:[],position:"auto",positionElement:void 0,prevArrow:"<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 17 17'><g></g><path d='M5.207 8.471l7.146 7.147-0.707 0.707-7.853-7.854 7.854-7.853 0.707 0.707-7.147 7.146z' /></svg>",shorthandCurrentMonth:!1,showMonths:1,static:!1,time_24hr:!1,weekNumbers:!1,wrap:!1},i={weekdays:{shorthand:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],longhand:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},months:{shorthand:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],longhand:["January","February","March","April","May","June","July","August","September","October","November","December"]},daysInMonth:[31,28,31,30,31,30,31,31,30,31,30,31],firstDayOfWeek:0,ordinal:function(e){var n=e%100;if(n>3&&n<21)return"th";switch(n%10){case 1:return"st";case 2:return"nd";case 3:return"rd";default:return"th"}},rangeSeparator:" to ",weekAbbreviation:"Wk",scrollTitle:"Scroll to increment",toggleTitle:"Click to toggle",amPM:["AM","PM"],yearAriaLabel:"Year",monthAriaLabel:"Month",hourAriaLabel:"Hour",minuteAriaLabel:"Minute",time_24hr:!1},o=function(e,n){return void 0===n&&(n=2),("000"+e).slice(-1*n)},r=function(e){return!0===e?1:0};function l(e,n){var t;return function(){var a=this,i=arguments;clearTimeout(t),t=setTimeout((function(){return e.apply(a,i)}),n)}}var c=function(e){return e instanceof Array?e:[e]};function s(e,n,t){if(!0===t)return e.classList.add(n);e.classList.remove(n)}function d(e,n,t){var a=window.document.createElement(e);return n=n||"",t=t||"",a.className=n,void 0!==t&&(a.textContent=t),a}function u(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function f(e,n){return n(e)?e:e.parentNode?f(e.parentNode,n):void 0}function m(e,n){var t=d("div","numInputWrapper"),a=d("input","numInput "+e),i=d("span","arrowUp"),o=d("span","arrowDown");if(-1===navigator.userAgent.indexOf("MSIE 9.0")?a.type="number":(a.type="text",a.pattern="\\d*"),void 0!==n)for(var r in n)a.setAttribute(r,n[r]);return t.appendChild(a),t.appendChild(i),t.appendChild(o),t}function g(e){try{return"function"==typeof e.composedPath?e.composedPath()[0]:e.target}catch(n){return e.target}}var p=function(){},h=function(e,n,t){return t.months[n?"shorthand":"longhand"][e]},v={D:p,F:function(e,n,t){e.setMonth(t.months.longhand.indexOf(n))},G:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},H:function(e,n){e.setHours(parseFloat(n))},J:function(e,n){e.setDate(parseFloat(n))},K:function(e,n,t){e.setHours(e.getHours()%12+12*r(new RegExp(t.amPM[1],"i").test(n)))},M:function(e,n,t){e.setMonth(t.months.shorthand.indexOf(n))},S:function(e,n){e.setSeconds(parseFloat(n))},U:function(e,n){return new Date(1e3*parseFloat(n))},W:function(e,n,t){var a=parseInt(n),i=new Date(e.getFullYear(),0,2+7*(a-1),0,0,0,0);return i.setDate(i.getDate()-i.getDay()+t.firstDayOfWeek),i},Y:function(e,n){e.setFullYear(parseFloat(n))},Z:function(e,n){return new Date(n)},d:function(e,n){e.setDate(parseFloat(n))},h:function(e,n){e.setHours((e.getHours()>=12?12:0)+parseFloat(n))},i:function(e,n){e.setMinutes(parseFloat(n))},j:function(e,n){e.setDate(parseFloat(n))},l:p,m:function(e,n){e.setMonth(parseFloat(n)-1)},n:function(e,n){e.setMonth(parseFloat(n)-1)},s:function(e,n){e.setSeconds(parseFloat(n))},u:function(e,n){return new Date(parseFloat(n))},w:p,y:function(e,n){e.setFullYear(2e3+parseFloat(n))}},D={D:"",F:"",G:"(\\d\\d|\\d)",H:"(\\d\\d|\\d)",J:"(\\d\\d|\\d)\\w+",K:"",M:"",S:"(\\d\\d|\\d)",U:"(.+)",W:"(\\d\\d|\\d)",Y:"(\\d{4})",Z:"(.+)",d:"(\\d\\d|\\d)",h:"(\\d\\d|\\d)",i:"(\\d\\d|\\d)",j:"(\\d\\d|\\d)",l:"",m:"(\\d\\d|\\d)",n:"(\\d\\d|\\d)",s:"(\\d\\d|\\d)",u:"(.+)",w:"(\\d\\d|\\d)",y:"(\\d{2})"},w={Z:function(e){return e.toISOString()},D:function(e,n,t){return n.weekdays.shorthand[w.w(e,n,t)]},F:function(e,n,t){return h(w.n(e,n,t)-1,!1,n)},G:function(e,n,t){return o(w.h(e,n,t))},H:function(e){return o(e.getHours())},J:function(e,n){return void 0!==n.ordinal?e.getDate()+n.ordinal(e.getDate()):e.getDate()},K:function(e,n){return n.amPM[r(e.getHours()>11)]},M:function(e,n){return h(e.getMonth(),!0,n)},S:function(e){return o(e.getSeconds())},U:function(e){return e.getTime()/1e3},W:function(e,n,t){return t.getWeek(e)},Y:function(e){return o(e.getFullYear(),4)},d:function(e){return o(e.getDate())},h:function(e){return e.getHours()%12?e.getHours()%12:12},i:function(e){return o(e.getMinutes())},j:function(e){return e.getDate()},l:function(e,n){return n.weekdays.longhand[e.getDay()]},m:function(e){return o(e.getMonth()+1)},n:function(e){return e.getMonth()+1},s:function(e){return e.getSeconds()},u:function(e){return e.getTime()},w:function(e){return e.getDay()},y:function(e){return String(e.getFullYear()).substring(2)}},b=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o,l=e.isMobile,c=void 0!==l&&l;return function(e,n,a){var i=a||r;return void 0===t.formatDate||c?n.split("").map((function(n,a,o){return w[n]&&"\\"!==o[a-1]?w[n](e,i,t):"\\"!==n?n:""})).join(""):t.formatDate(e,n,i)}},C=function(e){var n=e.config,t=void 0===n?a:n,o=e.l10n,r=void 0===o?i:o;return function(e,n,i,o){if(0===e||e){var l,c=o||r,s=e;if(e instanceof Date)l=new Date(e.getTime());else if("string"!=typeof e&&void 0!==e.toFixed)l=new Date(e);else if("string"==typeof e){var d=n||(t||a).dateFormat,u=String(e).trim();if("today"===u)l=new Date,i=!0;else if(t&&t.parseDate)l=t.parseDate(e,d);else if(/Z$/.test(u)||/GMT$/.test(u))l=new Date(e);else{for(var f=void 0,m=[],g=0,p=0,h="";g<d.length;g++){var w=d[g],b="\\"===w,C="\\"===d[g-1]||b;if(D[w]&&!C){h+=D[w];var M=new RegExp(h).exec(e);M&&(f=!0)&&m["Y"!==w?"push":"unshift"]({fn:v[w],val:M[++p]})}else b||(h+=".")}l=t&&t.noCalendar?new Date((new Date).setHours(0,0,0,0)):new Date((new Date).getFullYear(),0,1,0,0,0,0),m.forEach((function(e){var n=e.fn,t=e.val;return l=n(l,t,c)||l})),l=f?l:void 0}}if(l instanceof Date&&!isNaN(l.getTime()))return!0===i&&l.setHours(0,0,0,0),l;t.errorHandler(new Error("Invalid date provided: "+s))}}};function M(e,n,t){return void 0===t&&(t=!0),!1!==t?new Date(e.getTime()).setHours(0,0,0,0)-new Date(n.getTime()).setHours(0,0,0,0):e.getTime()-n.getTime()}var y=function(e,n,t){return 3600*e+60*n+t},x=864e5;function E(e){var n=e.defaultHour,t=e.defaultMinute,a=e.defaultSeconds;if(void 0!==e.minDate){var i=e.minDate.getHours(),o=e.minDate.getMinutes(),r=e.minDate.getSeconds();n<i&&(n=i),n===i&&t<o&&(t=o),n===i&&t===o&&a<r&&(a=e.minDate.getSeconds())}if(void 0!==e.maxDate){var l=e.maxDate.getHours(),c=e.maxDate.getMinutes();(n=Math.min(n,l))===l&&(t=Math.min(c,t)),n===l&&t===c&&(a=e.maxDate.getSeconds())}return{hours:n,minutes:t,seconds:a}}"function"!=typeof Object.assign&&(Object.assign=function(e){for(var n=[],t=1;t<arguments.length;t++)n[t-1]=arguments[t];if(!e)throw TypeError("Cannot convert undefined or null to object");for(var a=function(n){n&&Object.keys(n).forEach((function(t){return e[t]=n[t]}))},i=0,o=n;i<o.length;i++){var r=o[i];a(r)}return e});function k(p,v){var w={config:e(e({},a),I.defaultConfig),l10n:i};function k(){var e;return(null===(e=w.calendarContainer)||void 0===e?void 0:e.getRootNode()).activeElement||document.activeElement}function T(e){return e.bind(w)}function S(){var e=w.config;!1===e.weekNumbers&&1===e.showMonths||!0!==e.noCalendar&&window.requestAnimationFrame((function(){if(void 0!==w.calendarContainer&&(w.calendarContainer.style.visibility="hidden",w.calendarContainer.style.display="block"),void 0!==w.daysContainer){var n=(w.days.offsetWidth+1)*e.showMonths;w.daysContainer.style.width=n+"px",w.calendarContainer.style.width=n+(void 0!==w.weekWrapper?w.weekWrapper.offsetWidth:0)+"px",w.calendarContainer.style.removeProperty("visibility"),w.calendarContainer.style.removeProperty("display")}}))}function _(e){if(0===w.selectedDates.length){var n=void 0===w.config.minDate||M(new Date,w.config.minDate)>=0?new Date:new Date(w.config.minDate.getTime()),t=E(w.config);n.setHours(t.hours,t.minutes,t.seconds,n.getMilliseconds()),w.selectedDates=[n],w.latestSelectedDateObj=n}void 0!==e&&"blur"!==e.type&&function(e){e.preventDefault();var n="keydown"===e.type,t=g(e),a=t;void 0!==w.amPM&&t===w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]);var i=parseFloat(a.getAttribute("min")),l=parseFloat(a.getAttribute("max")),c=parseFloat(a.getAttribute("step")),s=parseInt(a.value,10),d=e.delta||(n?38===e.which?1:-1:0),u=s+c*d;if(void 0!==a.value&&2===a.value.length){var f=a===w.hourElement,m=a===w.minuteElement;u<i?(u=l+u+r(!f)+(r(f)&&r(!w.amPM)),m&&L(void 0,-1,w.hourElement)):u>l&&(u=a===w.hourElement?u-l-r(!w.amPM):i,m&&L(void 0,1,w.hourElement)),w.amPM&&f&&(1===c?u+s===23:Math.abs(u-s)>c)&&(w.amPM.textContent=w.l10n.amPM[r(w.amPM.textContent===w.l10n.amPM[0])]),a.value=o(u)}}(e);var a=w._input.value;O(),ye(),w._input.value!==a&&w._debouncedChange()}function O(){if(void 0!==w.hourElement&&void 0!==w.minuteElement){var e,n,t=(parseInt(w.hourElement.value.slice(-2),10)||0)%24,a=(parseInt(w.minuteElement.value,10)||0)%60,i=void 0!==w.secondElement?(parseInt(w.secondElement.value,10)||0)%60:0;void 0!==w.amPM&&(e=t,n=w.amPM.textContent,t=e%12+12*r(n===w.l10n.amPM[1]));var o=void 0!==w.config.minTime||w.config.minDate&&w.minDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.minDate,!0),l=void 0!==w.config.maxTime||w.config.maxDate&&w.maxDateHasTime&&w.latestSelectedDateObj&&0===M(w.latestSelectedDateObj,w.config.maxDate,!0);if(void 0!==w.config.maxTime&&void 0!==w.config.minTime&&w.config.minTime>w.config.maxTime){var c=y(w.config.minTime.getHours(),w.config.minTime.getMinutes(),w.config.minTime.getSeconds()),s=y(w.config.maxTime.getHours(),w.config.maxTime.getMinutes(),w.config.maxTime.getSeconds()),d=y(t,a,i);if(d>s&&d<c){var u=function(e){var n=Math.floor(e/3600),t=(e-3600*n)/60;return[n,t,e-3600*n-60*t]}(c);t=u[0],a=u[1],i=u[2]}}else{if(l){var f=void 0!==w.config.maxTime?w.config.maxTime:w.config.maxDate;(t=Math.min(t,f.getHours()))===f.getHours()&&(a=Math.min(a,f.getMinutes())),a===f.getMinutes()&&(i=Math.min(i,f.getSeconds()))}if(o){var m=void 0!==w.config.minTime?w.config.minTime:w.config.minDate;(t=Math.max(t,m.getHours()))===m.getHours()&&a<m.getMinutes()&&(a=m.getMinutes()),a===m.getMinutes()&&(i=Math.max(i,m.getSeconds()))}}A(t,a,i)}}function F(e){var n=e||w.latestSelectedDateObj;n&&n instanceof Date&&A(n.getHours(),n.getMinutes(),n.getSeconds())}function A(e,n,t){void 0!==w.latestSelectedDateObj&&w.latestSelectedDateObj.setHours(e%24,n,t||0,0),w.hourElement&&w.minuteElement&&!w.isMobile&&(w.hourElement.value=o(w.config.time_24hr?e:(12+e)%12+12*r(e%12==0)),w.minuteElement.value=o(n),void 0!==w.amPM&&(w.amPM.textContent=w.l10n.amPM[r(e>=12)]),void 0!==w.secondElement&&(w.secondElement.value=o(t)))}function N(e){var n=g(e),t=parseInt(n.value)+(e.delta||0);(t/1e3>1||"Enter"===e.key&&!/[^\d]/.test(t.toString()))&&ee(t)}function P(e,n,t,a){return n instanceof Array?n.forEach((function(n){return P(e,n,t,a)})):e instanceof Array?e.forEach((function(e){return P(e,n,t,a)})):(e.addEventListener(n,t,a),void w._handlers.push({remove:function(){return e.removeEventListener(n,t,a)}}))}function Y(){De("onChange")}function j(e,n){var t=void 0!==e?w.parseDate(e):w.latestSelectedDateObj||(w.config.minDate&&w.config.minDate>w.now?w.config.minDate:w.config.maxDate&&w.config.maxDate<w.now?w.config.maxDate:w.now),a=w.currentYear,i=w.currentMonth;try{void 0!==t&&(w.currentYear=t.getFullYear(),w.currentMonth=t.getMonth())}catch(e){e.message="Invalid date supplied: "+t,w.config.errorHandler(e)}n&&w.currentYear!==a&&(De("onYearChange"),q()),!n||w.currentYear===a&&w.currentMonth===i||De("onMonthChange"),w.redraw()}function H(e){var n=g(e);~n.className.indexOf("arrow")&&L(e,n.classList.contains("arrowUp")?1:-1)}function L(e,n,t){var a=e&&g(e),i=t||a&&a.parentNode&&a.parentNode.firstChild,o=we("increment");o.delta=n,i&&i.dispatchEvent(o)}function R(e,n,t,a){var i=ne(n,!0),o=d("span",e,n.getDate().toString());return o.dateObj=n,o.$i=a,o.setAttribute("aria-label",w.formatDate(n,w.config.ariaDateFormat)),-1===e.indexOf("hidden")&&0===M(n,w.now)&&(w.todayDateElem=o,o.classList.add("today"),o.setAttribute("aria-current","date")),i?(o.tabIndex=-1,be(n)&&(o.classList.add("selected"),w.selectedDateElem=o,"range"===w.config.mode&&(s(o,"startRange",w.selectedDates[0]&&0===M(n,w.selectedDates[0],!0)),s(o,"endRange",w.selectedDates[1]&&0===M(n,w.selectedDates[1],!0)),"nextMonthDay"===e&&o.classList.add("inRange")))):o.classList.add("flatpickr-disabled"),"range"===w.config.mode&&function(e){return!("range"!==w.config.mode||w.selectedDates.length<2)&&(M(e,w.selectedDates[0])>=0&&M(e,w.selectedDates[1])<=0)}(n)&&!be(n)&&o.classList.add("inRange"),w.weekNumbers&&1===w.config.showMonths&&"prevMonthDay"!==e&&a%7==6&&w.weekNumbers.insertAdjacentHTML("beforeend","<span class='flatpickr-day'>"+w.config.getWeek(n)+"</span>"),De("onDayCreate",o),o}function W(e){e.focus(),"range"===w.config.mode&&oe(e)}function B(e){for(var n=e>0?0:w.config.showMonths-1,t=e>0?w.config.showMonths:-1,a=n;a!=t;a+=e)for(var i=w.daysContainer.children[a],o=e>0?0:i.children.length-1,r=e>0?i.children.length:-1,l=o;l!=r;l+=e){var c=i.children[l];if(-1===c.className.indexOf("hidden")&&ne(c.dateObj))return c}}function J(e,n){var t=k(),a=te(t||document.body),i=void 0!==e?e:a?t:void 0!==w.selectedDateElem&&te(w.selectedDateElem)?w.selectedDateElem:void 0!==w.todayDateElem&&te(w.todayDateElem)?w.todayDateElem:B(n>0?1:-1);void 0===i?w._input.focus():a?function(e,n){for(var t=-1===e.className.indexOf("Month")?e.dateObj.getMonth():w.currentMonth,a=n>0?w.config.showMonths:-1,i=n>0?1:-1,o=t-w.currentMonth;o!=a;o+=i)for(var r=w.daysContainer.children[o],l=t-w.currentMonth===o?e.$i+n:n<0?r.children.length-1:0,c=r.children.length,s=l;s>=0&&s<c&&s!=(n>0?c:-1);s+=i){var d=r.children[s];if(-1===d.className.indexOf("hidden")&&ne(d.dateObj)&&Math.abs(e.$i-s)>=Math.abs(n))return W(d)}w.changeMonth(i),J(B(i),0)}(i,n):W(i)}function K(e,n){for(var t=(new Date(e,n,1).getDay()-w.l10n.firstDayOfWeek+7)%7,a=w.utils.getDaysInMonth((n-1+12)%12,e),i=w.utils.getDaysInMonth(n,e),o=window.document.createDocumentFragment(),r=w.config.showMonths>1,l=r?"prevMonthDay hidden":"prevMonthDay",c=r?"nextMonthDay hidden":"nextMonthDay",s=a+1-t,u=0;s<=a;s++,u++)o.appendChild(R("flatpickr-day "+l,new Date(e,n-1,s),0,u));for(s=1;s<=i;s++,u++)o.appendChild(R("flatpickr-day",new Date(e,n,s),0,u));for(var f=i+1;f<=42-t&&(1===w.config.showMonths||u%7!=0);f++,u++)o.appendChild(R("flatpickr-day "+c,new Date(e,n+1,f%i),0,u));var m=d("div","dayContainer");return m.appendChild(o),m}function U(){if(void 0!==w.daysContainer){u(w.daysContainer),w.weekNumbers&&u(w.weekNumbers);for(var e=document.createDocumentFragment(),n=0;n<w.config.showMonths;n++){var t=new Date(w.currentYear,w.currentMonth,1);t.setMonth(w.currentMonth+n),e.appendChild(K(t.getFullYear(),t.getMonth()))}w.daysContainer.appendChild(e),w.days=w.daysContainer.firstChild,"range"===w.config.mode&&1===w.selectedDates.length&&oe()}}function q(){if(!(w.config.showMonths>1||"dropdown"!==w.config.monthSelectorType)){var e=function(e){return!(void 0!==w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&e<w.config.minDate.getMonth())&&!(void 0!==w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()&&e>w.config.maxDate.getMonth())};w.monthsDropdownContainer.tabIndex=-1,w.monthsDropdownContainer.innerHTML="";for(var n=0;n<12;n++)if(e(n)){var t=d("option","flatpickr-monthDropdown-month");t.value=new Date(w.currentYear,n).getMonth().toString(),t.textContent=h(n,w.config.shorthandCurrentMonth,w.l10n),t.tabIndex=-1,w.currentMonth===n&&(t.selected=!0),w.monthsDropdownContainer.appendChild(t)}}}function $(){var e,n=d("div","flatpickr-month"),t=window.document.createDocumentFragment();w.config.showMonths>1||"static"===w.config.monthSelectorType?e=d("span","cur-month"):(w.monthsDropdownContainer=d("select","flatpickr-monthDropdown-months"),w.monthsDropdownContainer.setAttribute("aria-label",w.l10n.monthAriaLabel),P(w.monthsDropdownContainer,"change",(function(e){var n=g(e),t=parseInt(n.value,10);w.changeMonth(t-w.currentMonth),De("onMonthChange")})),q(),e=w.monthsDropdownContainer);var a=m("cur-year",{tabindex:"-1"}),i=a.getElementsByTagName("input")[0];i.setAttribute("aria-label",w.l10n.yearAriaLabel),w.config.minDate&&i.setAttribute("min",w.config.minDate.getFullYear().toString()),w.config.maxDate&&(i.setAttribute("max",w.config.maxDate.getFullYear().toString()),i.disabled=!!w.config.minDate&&w.config.minDate.getFullYear()===w.config.maxDate.getFullYear());var o=d("div","flatpickr-current-month");return o.appendChild(e),o.appendChild(a),t.appendChild(o),n.appendChild(t),{container:n,yearElement:i,monthElement:e}}function V(){u(w.monthNav),w.monthNav.appendChild(w.prevMonthNav),w.config.showMonths&&(w.yearElements=[],w.monthElements=[]);for(var e=w.config.showMonths;e--;){var n=$();w.yearElements.push(n.yearElement),w.monthElements.push(n.monthElement),w.monthNav.appendChild(n.container)}w.monthNav.appendChild(w.nextMonthNav)}function z(){w.weekdayContainer?u(w.weekdayContainer):w.weekdayContainer=d("div","flatpickr-weekdays");for(var e=w.config.showMonths;e--;){var n=d("div","flatpickr-weekdaycontainer");w.weekdayContainer.appendChild(n)}return G(),w.weekdayContainer}function G(){if(w.weekdayContainer){var e=w.l10n.firstDayOfWeek,t=n(w.l10n.weekdays.shorthand);e>0&&e<t.length&&(t=n(t.splice(e,t.length),t.splice(0,e)));for(var a=w.config.showMonths;a--;)w.weekdayContainer.children[a].innerHTML="\n      <span class='flatpickr-weekday'>\n        "+t.join("</span><span class='flatpickr-weekday'>")+"\n      </span>\n      "}}function Z(e,n){void 0===n&&(n=!0);var t=n?e:e-w.currentMonth;t<0&&!0===w._hidePrevMonthArrow||t>0&&!0===w._hideNextMonthArrow||(w.currentMonth+=t,(w.currentMonth<0||w.currentMonth>11)&&(w.currentYear+=w.currentMonth>11?1:-1,w.currentMonth=(w.currentMonth+12)%12,De("onYearChange"),q()),U(),De("onMonthChange"),Ce())}function Q(e){return w.calendarContainer.contains(e)}function X(e){if(w.isOpen&&!w.config.inline){var n=g(e),t=Q(n),a=!(n===w.input||n===w.altInput||w.element.contains(n)||e.path&&e.path.indexOf&&(~e.path.indexOf(w.input)||~e.path.indexOf(w.altInput)))&&!t&&!Q(e.relatedTarget),i=!w.config.ignoredFocusElements.some((function(e){return e.contains(n)}));a&&i&&(w.config.allowInput&&w.setDate(w._input.value,!1,w.config.altInput?w.config.altFormat:w.config.dateFormat),void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement&&""!==w.input.value&&void 0!==w.input.value&&_(),w.close(),w.config&&"range"===w.config.mode&&1===w.selectedDates.length&&w.clear(!1))}}function ee(e){if(!(!e||w.config.minDate&&e<w.config.minDate.getFullYear()||w.config.maxDate&&e>w.config.maxDate.getFullYear())){var n=e,t=w.currentYear!==n;w.currentYear=n||w.currentYear,w.config.maxDate&&w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth=Math.min(w.config.maxDate.getMonth(),w.currentMonth):w.config.minDate&&w.currentYear===w.config.minDate.getFullYear()&&(w.currentMonth=Math.max(w.config.minDate.getMonth(),w.currentMonth)),t&&(w.redraw(),De("onYearChange"),q())}}function ne(e,n){var t;void 0===n&&(n=!0);var a=w.parseDate(e,void 0,n);if(w.config.minDate&&a&&M(a,w.config.minDate,void 0!==n?n:!w.minDateHasTime)<0||w.config.maxDate&&a&&M(a,w.config.maxDate,void 0!==n?n:!w.maxDateHasTime)>0)return!1;if(!w.config.enable&&0===w.config.disable.length)return!0;if(void 0===a)return!1;for(var i=!!w.config.enable,o=null!==(t=w.config.enable)&&void 0!==t?t:w.config.disable,r=0,l=void 0;r<o.length;r++){if("function"==typeof(l=o[r])&&l(a))return i;if(l instanceof Date&&void 0!==a&&l.getTime()===a.getTime())return i;if("string"==typeof l){var c=w.parseDate(l,void 0,!0);return c&&c.getTime()===a.getTime()?i:!i}if("object"==typeof l&&void 0!==a&&l.from&&l.to&&a.getTime()>=l.from.getTime()&&a.getTime()<=l.to.getTime())return i}return!i}function te(e){return void 0!==w.daysContainer&&(-1===e.className.indexOf("hidden")&&-1===e.className.indexOf("flatpickr-disabled")&&w.daysContainer.contains(e))}function ae(e){var n=e.target===w._input,t=w._input.value.trimEnd()!==Me();!n||!t||e.relatedTarget&&Q(e.relatedTarget)||w.setDate(w._input.value,!0,e.target===w.altInput?w.config.altFormat:w.config.dateFormat)}function ie(e){var n=g(e),t=w.config.wrap?p.contains(n):n===w._input,a=w.config.allowInput,i=w.isOpen&&(!a||!t),o=w.config.inline&&t&&!a;if(13===e.keyCode&&t){if(a)return w.setDate(w._input.value,!0,n===w.altInput?w.config.altFormat:w.config.dateFormat),w.close(),n.blur();w.open()}else if(Q(n)||i||o){var r=!!w.timeContainer&&w.timeContainer.contains(n);switch(e.keyCode){case 13:r?(e.preventDefault(),_(),fe()):me(e);break;case 27:e.preventDefault(),fe();break;case 8:case 46:t&&!w.config.allowInput&&(e.preventDefault(),w.clear());break;case 37:case 39:if(r||t)w.hourElement&&w.hourElement.focus();else{e.preventDefault();var l=k();if(void 0!==w.daysContainer&&(!1===a||l&&te(l))){var c=39===e.keyCode?1:-1;e.ctrlKey?(e.stopPropagation(),Z(c),J(B(1),0)):J(void 0,c)}}break;case 38:case 40:e.preventDefault();var s=40===e.keyCode?1:-1;w.daysContainer&&void 0!==n.$i||n===w.input||n===w.altInput?e.ctrlKey?(e.stopPropagation(),ee(w.currentYear-s),J(B(1),0)):r||J(void 0,7*s):n===w.currentYearElement?ee(w.currentYear-s):w.config.enableTime&&(!r&&w.hourElement&&w.hourElement.focus(),_(e),w._debouncedChange());break;case 9:if(r){var d=[w.hourElement,w.minuteElement,w.secondElement,w.amPM].concat(w.pluginElements).filter((function(e){return e})),u=d.indexOf(n);if(-1!==u){var f=d[u+(e.shiftKey?-1:1)];e.preventDefault(),(f||w._input).focus()}}else!w.config.noCalendar&&w.daysContainer&&w.daysContainer.contains(n)&&e.shiftKey&&(e.preventDefault(),w._input.focus())}}if(void 0!==w.amPM&&n===w.amPM)switch(e.key){case w.l10n.amPM[0].charAt(0):case w.l10n.amPM[0].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[0],O(),ye();break;case w.l10n.amPM[1].charAt(0):case w.l10n.amPM[1].charAt(0).toLowerCase():w.amPM.textContent=w.l10n.amPM[1],O(),ye()}(t||Q(n))&&De("onKeyDown",e)}function oe(e,n){if(void 0===n&&(n="flatpickr-day"),1===w.selectedDates.length&&(!e||e.classList.contains(n)&&!e.classList.contains("flatpickr-disabled"))){for(var t=e?e.dateObj.getTime():w.days.firstElementChild.dateObj.getTime(),a=w.parseDate(w.selectedDates[0],void 0,!0).getTime(),i=Math.min(t,w.selectedDates[0].getTime()),o=Math.max(t,w.selectedDates[0].getTime()),r=!1,l=0,c=0,s=i;s<o;s+=x)ne(new Date(s),!0)||(r=r||s>i&&s<o,s<a&&(!l||s>l)?l=s:s>a&&(!c||s<c)&&(c=s));Array.from(w.rContainer.querySelectorAll("*:nth-child(-n+"+w.config.showMonths+") > ."+n)).forEach((function(n){var i,o,s,d=n.dateObj.getTime(),u=l>0&&d<l||c>0&&d>c;if(u)return n.classList.add("notAllowed"),void["inRange","startRange","endRange"].forEach((function(e){n.classList.remove(e)}));r&&!u||(["startRange","inRange","endRange","notAllowed"].forEach((function(e){n.classList.remove(e)})),void 0!==e&&(e.classList.add(t<=w.selectedDates[0].getTime()?"startRange":"endRange"),a<t&&d===a?n.classList.add("startRange"):a>t&&d===a&&n.classList.add("endRange"),d>=l&&(0===c||d<=c)&&(o=a,s=t,(i=d)>Math.min(o,s)&&i<Math.max(o,s))&&n.classList.add("inRange")))}))}}function re(){!w.isOpen||w.config.static||w.config.inline||de()}function le(e){return function(n){var t=w.config["_"+e+"Date"]=w.parseDate(n,w.config.dateFormat),a=w.config["_"+("min"===e?"max":"min")+"Date"];void 0!==t&&(w["min"===e?"minDateHasTime":"maxDateHasTime"]=t.getHours()>0||t.getMinutes()>0||t.getSeconds()>0),w.selectedDates&&(w.selectedDates=w.selectedDates.filter((function(e){return ne(e)})),w.selectedDates.length||"min"!==e||F(t),ye()),w.daysContainer&&(ue(),void 0!==t?w.currentYearElement[e]=t.getFullYear().toString():w.currentYearElement.removeAttribute(e),w.currentYearElement.disabled=!!a&&void 0!==t&&a.getFullYear()===t.getFullYear())}}function ce(){return w.config.wrap?p.querySelector("[data-input]"):p}function se(){"object"!=typeof w.config.locale&&void 0===I.l10ns[w.config.locale]&&w.config.errorHandler(new Error("flatpickr: invalid locale "+w.config.locale)),w.l10n=e(e({},I.l10ns.default),"object"==typeof w.config.locale?w.config.locale:"default"!==w.config.locale?I.l10ns[w.config.locale]:void 0),D.D="("+w.l10n.weekdays.shorthand.join("|")+")",D.l="("+w.l10n.weekdays.longhand.join("|")+")",D.M="("+w.l10n.months.shorthand.join("|")+")",D.F="("+w.l10n.months.longhand.join("|")+")",D.K="("+w.l10n.amPM[0]+"|"+w.l10n.amPM[1]+"|"+w.l10n.amPM[0].toLowerCase()+"|"+w.l10n.amPM[1].toLowerCase()+")",void 0===e(e({},v),JSON.parse(JSON.stringify(p.dataset||{}))).time_24hr&&void 0===I.defaultConfig.time_24hr&&(w.config.time_24hr=w.l10n.time_24hr),w.formatDate=b(w),w.parseDate=C({config:w.config,l10n:w.l10n})}function de(e){if("function"!=typeof w.config.position){if(void 0!==w.calendarContainer){De("onPreCalendarPosition");var n=e||w._positionElement,t=Array.prototype.reduce.call(w.calendarContainer.children,(function(e,n){return e+n.offsetHeight}),0),a=w.calendarContainer.offsetWidth,i=w.config.position.split(" "),o=i[0],r=i.length>1?i[1]:null,l=n.getBoundingClientRect(),c=window.innerHeight-l.bottom,d="above"===o||"below"!==o&&c<t&&l.top>t,u=window.pageYOffset+l.top+(d?-t-2:n.offsetHeight+2);if(s(w.calendarContainer,"arrowTop",!d),s(w.calendarContainer,"arrowBottom",d),!w.config.inline){var f=window.pageXOffset+l.left,m=!1,g=!1;"center"===r?(f-=(a-l.width)/2,m=!0):"right"===r&&(f-=a-l.width,g=!0),s(w.calendarContainer,"arrowLeft",!m&&!g),s(w.calendarContainer,"arrowCenter",m),s(w.calendarContainer,"arrowRight",g);var p=window.document.body.offsetWidth-(window.pageXOffset+l.right),h=f+a>window.document.body.offsetWidth,v=p+a>window.document.body.offsetWidth;if(s(w.calendarContainer,"rightMost",h),!w.config.static)if(w.calendarContainer.style.top=u+"px",h)if(v){var D=function(){for(var e=null,n=0;n<document.styleSheets.length;n++){var t=document.styleSheets[n];if(t.cssRules){try{t.cssRules}catch(e){continue}e=t;break}}return null!=e?e:(a=document.createElement("style"),document.head.appendChild(a),a.sheet);var a}();if(void 0===D)return;var b=window.document.body.offsetWidth,C=Math.max(0,b/2-a/2),M=D.cssRules.length,y="{left:"+l.left+"px;right:auto;}";s(w.calendarContainer,"rightMost",!1),s(w.calendarContainer,"centerMost",!0),D.insertRule(".flatpickr-calendar.centerMost:before,.flatpickr-calendar.centerMost:after"+y,M),w.calendarContainer.style.left=C+"px",w.calendarContainer.style.right="auto"}else w.calendarContainer.style.left="auto",w.calendarContainer.style.right=p+"px";else w.calendarContainer.style.left=f+"px",w.calendarContainer.style.right="auto"}}}else w.config.position(w,e)}function ue(){w.config.noCalendar||w.isMobile||(q(),Ce(),U())}function fe(){w._input.focus(),-1!==window.navigator.userAgent.indexOf("MSIE")||void 0!==navigator.msMaxTouchPoints?setTimeout(w.close,0):w.close()}function me(e){e.preventDefault(),e.stopPropagation();var n=f(g(e),(function(e){return e.classList&&e.classList.contains("flatpickr-day")&&!e.classList.contains("flatpickr-disabled")&&!e.classList.contains("notAllowed")}));if(void 0!==n){var t=n,a=w.latestSelectedDateObj=new Date(t.dateObj.getTime()),i=(a.getMonth()<w.currentMonth||a.getMonth()>w.currentMonth+w.config.showMonths-1)&&"range"!==w.config.mode;if(w.selectedDateElem=t,"single"===w.config.mode)w.selectedDates=[a];else if("multiple"===w.config.mode){var o=be(a);o?w.selectedDates.splice(parseInt(o),1):w.selectedDates.push(a)}else"range"===w.config.mode&&(2===w.selectedDates.length&&w.clear(!1,!1),w.latestSelectedDateObj=a,w.selectedDates.push(a),0!==M(a,w.selectedDates[0],!0)&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()})));if(O(),i){var r=w.currentYear!==a.getFullYear();w.currentYear=a.getFullYear(),w.currentMonth=a.getMonth(),r&&(De("onYearChange"),q()),De("onMonthChange")}if(Ce(),U(),ye(),i||"range"===w.config.mode||1!==w.config.showMonths?void 0!==w.selectedDateElem&&void 0===w.hourElement&&w.selectedDateElem&&w.selectedDateElem.focus():W(t),void 0!==w.hourElement&&void 0!==w.hourElement&&w.hourElement.focus(),w.config.closeOnSelect){var l="single"===w.config.mode&&!w.config.enableTime,c="range"===w.config.mode&&2===w.selectedDates.length&&!w.config.enableTime;(l||c)&&fe()}Y()}}w.parseDate=C({config:w.config,l10n:w.l10n}),w._handlers=[],w.pluginElements=[],w.loadedPlugins=[],w._bind=P,w._setHoursFromDate=F,w._positionCalendar=de,w.changeMonth=Z,w.changeYear=ee,w.clear=function(e,n){void 0===e&&(e=!0);void 0===n&&(n=!0);w.input.value="",void 0!==w.altInput&&(w.altInput.value="");void 0!==w.mobileInput&&(w.mobileInput.value="");w.selectedDates=[],w.latestSelectedDateObj=void 0,!0===n&&(w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth());if(!0===w.config.enableTime){var t=E(w.config),a=t.hours,i=t.minutes,o=t.seconds;A(a,i,o)}w.redraw(),e&&De("onChange")},w.close=function(){w.isOpen=!1,w.isMobile||(void 0!==w.calendarContainer&&w.calendarContainer.classList.remove("open"),void 0!==w._input&&w._input.classList.remove("active"));De("onClose")},w.onMouseOver=oe,w._createElement=d,w.createDay=R,w.destroy=function(){void 0!==w.config&&De("onDestroy");for(var e=w._handlers.length;e--;)w._handlers[e].remove();if(w._handlers=[],w.mobileInput)w.mobileInput.parentNode&&w.mobileInput.parentNode.removeChild(w.mobileInput),w.mobileInput=void 0;else if(w.calendarContainer&&w.calendarContainer.parentNode)if(w.config.static&&w.calendarContainer.parentNode){var n=w.calendarContainer.parentNode;if(n.lastChild&&n.removeChild(n.lastChild),n.parentNode){for(;n.firstChild;)n.parentNode.insertBefore(n.firstChild,n);n.parentNode.removeChild(n)}}else w.calendarContainer.parentNode.removeChild(w.calendarContainer);w.altInput&&(w.input.type="text",w.altInput.parentNode&&w.altInput.parentNode.removeChild(w.altInput),delete w.altInput);w.input&&(w.input.type=w.input._type,w.input.classList.remove("flatpickr-input"),w.input.removeAttribute("readonly"));["_showTimeInput","latestSelectedDateObj","_hideNextMonthArrow","_hidePrevMonthArrow","__hideNextMonthArrow","__hidePrevMonthArrow","isMobile","isOpen","selectedDateElem","minDateHasTime","maxDateHasTime","days","daysContainer","_input","_positionElement","innerContainer","rContainer","monthNav","todayDateElem","calendarContainer","weekdayContainer","prevMonthNav","nextMonthNav","monthsDropdownContainer","currentMonthElement","currentYearElement","navigationCurrentMonth","selectedDateElem","config"].forEach((function(e){try{delete w[e]}catch(e){}}))},w.isEnabled=ne,w.jumpToDate=j,w.updateValue=ye,w.open=function(e,n){void 0===n&&(n=w._positionElement);if(!0===w.isMobile){if(e){e.preventDefault();var t=g(e);t&&t.blur()}return void 0!==w.mobileInput&&(w.mobileInput.focus(),w.mobileInput.click()),void De("onOpen")}if(w._input.disabled||w.config.inline)return;var a=w.isOpen;w.isOpen=!0,a||(w.calendarContainer.classList.add("open"),w._input.classList.add("active"),De("onOpen"),de(n));!0===w.config.enableTime&&!0===w.config.noCalendar&&(!1!==w.config.allowInput||void 0!==e&&w.timeContainer.contains(e.relatedTarget)||setTimeout((function(){return w.hourElement.select()}),50))},w.redraw=ue,w.set=function(e,n){if(null!==e&&"object"==typeof e)for(var a in Object.assign(w.config,e),e)void 0!==ge[a]&&ge[a].forEach((function(e){return e()}));else w.config[e]=n,void 0!==ge[e]?ge[e].forEach((function(e){return e()})):t.indexOf(e)>-1&&(w.config[e]=c(n));w.redraw(),ye(!0)},w.setDate=function(e,n,t){void 0===n&&(n=!1);void 0===t&&(t=w.config.dateFormat);if(0!==e&&!e||e instanceof Array&&0===e.length)return w.clear(n);pe(e,t),w.latestSelectedDateObj=w.selectedDates[w.selectedDates.length-1],w.redraw(),j(void 0,n),F(),0===w.selectedDates.length&&w.clear(!1);ye(n),n&&De("onChange")},w.toggle=function(e){if(!0===w.isOpen)return w.close();w.open(e)};var ge={locale:[se,G],showMonths:[V,S,z],minDate:[j],maxDate:[j],positionElement:[ve],clickOpens:[function(){!0===w.config.clickOpens?(P(w._input,"focus",w.open),P(w._input,"click",w.open)):(w._input.removeEventListener("focus",w.open),w._input.removeEventListener("click",w.open))}]};function pe(e,n){var t=[];if(e instanceof Array)t=e.map((function(e){return w.parseDate(e,n)}));else if(e instanceof Date||"number"==typeof e)t=[w.parseDate(e,n)];else if("string"==typeof e)switch(w.config.mode){case"single":case"time":t=[w.parseDate(e,n)];break;case"multiple":t=e.split(w.config.conjunction).map((function(e){return w.parseDate(e,n)}));break;case"range":t=e.split(w.l10n.rangeSeparator).map((function(e){return w.parseDate(e,n)}))}else w.config.errorHandler(new Error("Invalid date supplied: "+JSON.stringify(e)));w.selectedDates=w.config.allowInvalidPreload?t:t.filter((function(e){return e instanceof Date&&ne(e,!1)})),"range"===w.config.mode&&w.selectedDates.sort((function(e,n){return e.getTime()-n.getTime()}))}function he(e){return e.slice().map((function(e){return"string"==typeof e||"number"==typeof e||e instanceof Date?w.parseDate(e,void 0,!0):e&&"object"==typeof e&&e.from&&e.to?{from:w.parseDate(e.from,void 0),to:w.parseDate(e.to,void 0)}:e})).filter((function(e){return e}))}function ve(){w._positionElement=w.config.positionElement||w._input}function De(e,n){if(void 0!==w.config){var t=w.config[e];if(void 0!==t&&t.length>0)for(var a=0;t[a]&&a<t.length;a++)t[a](w.selectedDates,w.input.value,w,n);"onChange"===e&&(w.input.dispatchEvent(we("change")),w.input.dispatchEvent(we("input")))}}function we(e){var n=document.createEvent("Event");return n.initEvent(e,!0,!0),n}function be(e){for(var n=0;n<w.selectedDates.length;n++){var t=w.selectedDates[n];if(t instanceof Date&&0===M(t,e))return""+n}return!1}function Ce(){w.config.noCalendar||w.isMobile||!w.monthNav||(w.yearElements.forEach((function(e,n){var t=new Date(w.currentYear,w.currentMonth,1);t.setMonth(w.currentMonth+n),w.config.showMonths>1||"static"===w.config.monthSelectorType?w.monthElements[n].textContent=h(t.getMonth(),w.config.shorthandCurrentMonth,w.l10n)+" ":w.monthsDropdownContainer.value=t.getMonth().toString(),e.value=t.getFullYear().toString()})),w._hidePrevMonthArrow=void 0!==w.config.minDate&&(w.currentYear===w.config.minDate.getFullYear()?w.currentMonth<=w.config.minDate.getMonth():w.currentYear<w.config.minDate.getFullYear()),w._hideNextMonthArrow=void 0!==w.config.maxDate&&(w.currentYear===w.config.maxDate.getFullYear()?w.currentMonth+1>w.config.maxDate.getMonth():w.currentYear>w.config.maxDate.getFullYear()))}function Me(e){var n=e||(w.config.altInput?w.config.altFormat:w.config.dateFormat);return w.selectedDates.map((function(e){return w.formatDate(e,n)})).filter((function(e,n,t){return"range"!==w.config.mode||w.config.enableTime||t.indexOf(e)===n})).join("range"!==w.config.mode?w.config.conjunction:w.l10n.rangeSeparator)}function ye(e){void 0===e&&(e=!0),void 0!==w.mobileInput&&w.mobileFormatStr&&(w.mobileInput.value=void 0!==w.latestSelectedDateObj?w.formatDate(w.latestSelectedDateObj,w.mobileFormatStr):""),w.input.value=Me(w.config.dateFormat),void 0!==w.altInput&&(w.altInput.value=Me(w.config.altFormat)),!1!==e&&De("onValueUpdate")}function xe(e){var n=g(e),t=w.prevMonthNav.contains(n),a=w.nextMonthNav.contains(n);t||a?Z(t?-1:1):w.yearElements.indexOf(n)>=0?n.select():n.classList.contains("arrowUp")?w.changeYear(w.currentYear+1):n.classList.contains("arrowDown")&&w.changeYear(w.currentYear-1)}return function(){w.element=w.input=p,w.isOpen=!1,function(){var n=["wrap","weekNumbers","allowInput","allowInvalidPreload","clickOpens","time_24hr","enableTime","noCalendar","altInput","shorthandCurrentMonth","inline","static","enableSeconds","disableMobile"],i=e(e({},JSON.parse(JSON.stringify(p.dataset||{}))),v),o={};w.config.parseDate=i.parseDate,w.config.formatDate=i.formatDate,Object.defineProperty(w.config,"enable",{get:function(){return w.config._enable},set:function(e){w.config._enable=he(e)}}),Object.defineProperty(w.config,"disable",{get:function(){return w.config._disable},set:function(e){w.config._disable=he(e)}});var r="time"===i.mode;if(!i.dateFormat&&(i.enableTime||r)){var l=I.defaultConfig.dateFormat||a.dateFormat;o.dateFormat=i.noCalendar||r?"H:i"+(i.enableSeconds?":S":""):l+" H:i"+(i.enableSeconds?":S":"")}if(i.altInput&&(i.enableTime||r)&&!i.altFormat){var s=I.defaultConfig.altFormat||a.altFormat;o.altFormat=i.noCalendar||r?"h:i"+(i.enableSeconds?":S K":" K"):s+" h:i"+(i.enableSeconds?":S":"")+" K"}Object.defineProperty(w.config,"minDate",{get:function(){return w.config._minDate},set:le("min")}),Object.defineProperty(w.config,"maxDate",{get:function(){return w.config._maxDate},set:le("max")});var d=function(e){return function(n){w.config["min"===e?"_minTime":"_maxTime"]=w.parseDate(n,"H:i:S")}};Object.defineProperty(w.config,"minTime",{get:function(){return w.config._minTime},set:d("min")}),Object.defineProperty(w.config,"maxTime",{get:function(){return w.config._maxTime},set:d("max")}),"time"===i.mode&&(w.config.noCalendar=!0,w.config.enableTime=!0);Object.assign(w.config,o,i);for(var u=0;u<n.length;u++)w.config[n[u]]=!0===w.config[n[u]]||"true"===w.config[n[u]];t.filter((function(e){return void 0!==w.config[e]})).forEach((function(e){w.config[e]=c(w.config[e]||[]).map(T)})),w.isMobile=!w.config.disableMobile&&!w.config.inline&&"single"===w.config.mode&&!w.config.disable.length&&!w.config.enable&&!w.config.weekNumbers&&/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);for(u=0;u<w.config.plugins.length;u++){var f=w.config.plugins[u](w)||{};for(var m in f)t.indexOf(m)>-1?w.config[m]=c(f[m]).map(T).concat(w.config[m]):void 0===i[m]&&(w.config[m]=f[m])}i.altInputClass||(w.config.altInputClass=ce().className+" "+w.config.altInputClass);De("onParseConfig")}(),se(),function(){if(w.input=ce(),!w.input)return void w.config.errorHandler(new Error("Invalid input element specified"));w.input._type=w.input.type,w.input.type="text",w.input.classList.add("flatpickr-input"),w._input=w.input,w.config.altInput&&(w.altInput=d(w.input.nodeName,w.config.altInputClass),w._input=w.altInput,w.altInput.placeholder=w.input.placeholder,w.altInput.disabled=w.input.disabled,w.altInput.required=w.input.required,w.altInput.tabIndex=w.input.tabIndex,w.altInput.type="text",w.input.setAttribute("type","hidden"),!w.config.static&&w.input.parentNode&&w.input.parentNode.insertBefore(w.altInput,w.input.nextSibling));w.config.allowInput||w._input.setAttribute("readonly","readonly");ve()}(),function(){w.selectedDates=[],w.now=w.parseDate(w.config.now)||new Date;var e=w.config.defaultDate||("INPUT"!==w.input.nodeName&&"TEXTAREA"!==w.input.nodeName||!w.input.placeholder||w.input.value!==w.input.placeholder?w.input.value:null);e&&pe(e,w.config.dateFormat);w._initialDate=w.selectedDates.length>0?w.selectedDates[0]:w.config.minDate&&w.config.minDate.getTime()>w.now.getTime()?w.config.minDate:w.config.maxDate&&w.config.maxDate.getTime()<w.now.getTime()?w.config.maxDate:w.now,w.currentYear=w._initialDate.getFullYear(),w.currentMonth=w._initialDate.getMonth(),w.selectedDates.length>0&&(w.latestSelectedDateObj=w.selectedDates[0]);void 0!==w.config.minTime&&(w.config.minTime=w.parseDate(w.config.minTime,"H:i"));void 0!==w.config.maxTime&&(w.config.maxTime=w.parseDate(w.config.maxTime,"H:i"));w.minDateHasTime=!!w.config.minDate&&(w.config.minDate.getHours()>0||w.config.minDate.getMinutes()>0||w.config.minDate.getSeconds()>0),w.maxDateHasTime=!!w.config.maxDate&&(w.config.maxDate.getHours()>0||w.config.maxDate.getMinutes()>0||w.config.maxDate.getSeconds()>0)}(),w.utils={getDaysInMonth:function(e,n){return void 0===e&&(e=w.currentMonth),void 0===n&&(n=w.currentYear),1===e&&(n%4==0&&n%100!=0||n%400==0)?29:w.l10n.daysInMonth[e]}},w.isMobile||function(){var e=window.document.createDocumentFragment();if(w.calendarContainer=d("div","flatpickr-calendar"),w.calendarContainer.tabIndex=-1,!w.config.noCalendar){if(e.appendChild((w.monthNav=d("div","flatpickr-months"),w.yearElements=[],w.monthElements=[],w.prevMonthNav=d("span","flatpickr-prev-month"),w.prevMonthNav.innerHTML=w.config.prevArrow,w.nextMonthNav=d("span","flatpickr-next-month"),w.nextMonthNav.innerHTML=w.config.nextArrow,V(),Object.defineProperty(w,"_hidePrevMonthArrow",{get:function(){return w.__hidePrevMonthArrow},set:function(e){w.__hidePrevMonthArrow!==e&&(s(w.prevMonthNav,"flatpickr-disabled",e),w.__hidePrevMonthArrow=e)}}),Object.defineProperty(w,"_hideNextMonthArrow",{get:function(){return w.__hideNextMonthArrow},set:function(e){w.__hideNextMonthArrow!==e&&(s(w.nextMonthNav,"flatpickr-disabled",e),w.__hideNextMonthArrow=e)}}),w.currentYearElement=w.yearElements[0],Ce(),w.monthNav)),w.innerContainer=d("div","flatpickr-innerContainer"),w.config.weekNumbers){var n=function(){w.calendarContainer.classList.add("hasWeeks");var e=d("div","flatpickr-weekwrapper");e.appendChild(d("span","flatpickr-weekday",w.l10n.weekAbbreviation));var n=d("div","flatpickr-weeks");return e.appendChild(n),{weekWrapper:e,weekNumbers:n}}(),t=n.weekWrapper,a=n.weekNumbers;w.innerContainer.appendChild(t),w.weekNumbers=a,w.weekWrapper=t}w.rContainer=d("div","flatpickr-rContainer"),w.rContainer.appendChild(z()),w.daysContainer||(w.daysContainer=d("div","flatpickr-days"),w.daysContainer.tabIndex=-1),U(),w.rContainer.appendChild(w.daysContainer),w.innerContainer.appendChild(w.rContainer),e.appendChild(w.innerContainer)}w.config.enableTime&&e.appendChild(function(){w.calendarContainer.classList.add("hasTime"),w.config.noCalendar&&w.calendarContainer.classList.add("noCalendar");var e=E(w.config);w.timeContainer=d("div","flatpickr-time"),w.timeContainer.tabIndex=-1;var n=d("span","flatpickr-time-separator",":"),t=m("flatpickr-hour",{"aria-label":w.l10n.hourAriaLabel});w.hourElement=t.getElementsByTagName("input")[0];var a=m("flatpickr-minute",{"aria-label":w.l10n.minuteAriaLabel});w.minuteElement=a.getElementsByTagName("input")[0],w.hourElement.tabIndex=w.minuteElement.tabIndex=-1,w.hourElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getHours():w.config.time_24hr?e.hours:function(e){switch(e%24){case 0:case 12:return 12;default:return e%12}}(e.hours)),w.minuteElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getMinutes():e.minutes),w.hourElement.setAttribute("step",w.config.hourIncrement.toString()),w.minuteElement.setAttribute("step",w.config.minuteIncrement.toString()),w.hourElement.setAttribute("min",w.config.time_24hr?"0":"1"),w.hourElement.setAttribute("max",w.config.time_24hr?"23":"12"),w.hourElement.setAttribute("maxlength","2"),w.minuteElement.setAttribute("min","0"),w.minuteElement.setAttribute("max","59"),w.minuteElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(t),w.timeContainer.appendChild(n),w.timeContainer.appendChild(a),w.config.time_24hr&&w.timeContainer.classList.add("time24hr");if(w.config.enableSeconds){w.timeContainer.classList.add("hasSeconds");var i=m("flatpickr-second");w.secondElement=i.getElementsByTagName("input")[0],w.secondElement.value=o(w.latestSelectedDateObj?w.latestSelectedDateObj.getSeconds():e.seconds),w.secondElement.setAttribute("step",w.minuteElement.getAttribute("step")),w.secondElement.setAttribute("min","0"),w.secondElement.setAttribute("max","59"),w.secondElement.setAttribute("maxlength","2"),w.timeContainer.appendChild(d("span","flatpickr-time-separator",":")),w.timeContainer.appendChild(i)}w.config.time_24hr||(w.amPM=d("span","flatpickr-am-pm",w.l10n.amPM[r((w.latestSelectedDateObj?w.hourElement.value:w.config.defaultHour)>11)]),w.amPM.title=w.l10n.toggleTitle,w.amPM.tabIndex=-1,w.timeContainer.appendChild(w.amPM));return w.timeContainer}());s(w.calendarContainer,"rangeMode","range"===w.config.mode),s(w.calendarContainer,"animate",!0===w.config.animate),s(w.calendarContainer,"multiMonth",w.config.showMonths>1),w.calendarContainer.appendChild(e);var i=void 0!==w.config.appendTo&&void 0!==w.config.appendTo.nodeType;if((w.config.inline||w.config.static)&&(w.calendarContainer.classList.add(w.config.inline?"inline":"static"),w.config.inline&&(!i&&w.element.parentNode?w.element.parentNode.insertBefore(w.calendarContainer,w._input.nextSibling):void 0!==w.config.appendTo&&w.config.appendTo.appendChild(w.calendarContainer)),w.config.static)){var l=d("div","flatpickr-wrapper");w.element.parentNode&&w.element.parentNode.insertBefore(l,w.element),l.appendChild(w.element),w.altInput&&l.appendChild(w.altInput),l.appendChild(w.calendarContainer)}w.config.static||w.config.inline||(void 0!==w.config.appendTo?w.config.appendTo:window.document.body).appendChild(w.calendarContainer)}(),function(){w.config.wrap&&["open","close","toggle","clear"].forEach((function(e){Array.prototype.forEach.call(w.element.querySelectorAll("[data-"+e+"]"),(function(n){return P(n,"click",w[e])}))}));if(w.isMobile)return void function(){var e=w.config.enableTime?w.config.noCalendar?"time":"datetime-local":"date";w.mobileInput=d("input",w.input.className+" flatpickr-mobile"),w.mobileInput.tabIndex=1,w.mobileInput.type=e,w.mobileInput.disabled=w.input.disabled,w.mobileInput.required=w.input.required,w.mobileInput.placeholder=w.input.placeholder,w.mobileFormatStr="datetime-local"===e?"Y-m-d\\TH:i:S":"date"===e?"Y-m-d":"H:i:S",w.selectedDates.length>0&&(w.mobileInput.defaultValue=w.mobileInput.value=w.formatDate(w.selectedDates[0],w.mobileFormatStr));w.config.minDate&&(w.mobileInput.min=w.formatDate(w.config.minDate,"Y-m-d"));w.config.maxDate&&(w.mobileInput.max=w.formatDate(w.config.maxDate,"Y-m-d"));w.input.getAttribute("step")&&(w.mobileInput.step=String(w.input.getAttribute("step")));w.input.type="hidden",void 0!==w.altInput&&(w.altInput.type="hidden");try{w.input.parentNode&&w.input.parentNode.insertBefore(w.mobileInput,w.input.nextSibling)}catch(e){}P(w.mobileInput,"change",(function(e){w.setDate(g(e).value,!1,w.mobileFormatStr),De("onChange"),De("onClose")}))}();var e=l(re,50);w._debouncedChange=l(Y,300),w.daysContainer&&!/iPhone|iPad|iPod/i.test(navigator.userAgent)&&P(w.daysContainer,"mouseover",(function(e){"range"===w.config.mode&&oe(g(e))}));P(w._input,"keydown",ie),void 0!==w.calendarContainer&&P(w.calendarContainer,"keydown",ie);w.config.inline||w.config.static||P(window,"resize",e);void 0!==window.ontouchstart?P(window.document,"touchstart",X):P(window.document,"mousedown",X);P(window.document,"focus",X,{capture:!0}),!0===w.config.clickOpens&&(P(w._input,"focus",w.open),P(w._input,"click",w.open));void 0!==w.daysContainer&&(P(w.monthNav,"click",xe),P(w.monthNav,["keyup","increment"],N),P(w.daysContainer,"click",me));if(void 0!==w.timeContainer&&void 0!==w.minuteElement&&void 0!==w.hourElement){var n=function(e){return g(e).select()};P(w.timeContainer,["increment"],_),P(w.timeContainer,"blur",_,{capture:!0}),P(w.timeContainer,"click",H),P([w.hourElement,w.minuteElement],["focus","click"],n),void 0!==w.secondElement&&P(w.secondElement,"focus",(function(){return w.secondElement&&w.secondElement.select()})),void 0!==w.amPM&&P(w.amPM,"click",(function(e){_(e)}))}w.config.allowInput&&P(w._input,"blur",ae)}(),(w.selectedDates.length||w.config.noCalendar)&&(w.config.enableTime&&F(w.config.noCalendar?w.latestSelectedDateObj:void 0),ye(!1)),S();var n=/^((?!chrome|android).)*safari/i.test(navigator.userAgent);!w.isMobile&&n&&de(),De("onReady")}(),w}function T(e,n){for(var t=Array.prototype.slice.call(e).filter((function(e){return e instanceof HTMLElement})),a=[],i=0;i<t.length;i++){var o=t[i];try{if(null!==o.getAttribute("data-fp-omit"))continue;void 0!==o._flatpickr&&(o._flatpickr.destroy(),o._flatpickr=void 0),o._flatpickr=k(o,n||{}),a.push(o._flatpickr)}catch(e){console.error(e)}}return 1===a.length?a[0]:a}"undefined"!=typeof HTMLElement&&"undefined"!=typeof HTMLCollection&&"undefined"!=typeof NodeList&&(HTMLCollection.prototype.flatpickr=NodeList.prototype.flatpickr=function(e){return T(this,e)},HTMLElement.prototype.flatpickr=function(e){return T([this],e)});var I=function(e,n){return"string"==typeof e?T(window.document.querySelectorAll(e),n):e instanceof Node?T([e],n):T(e,n)};return I.defaultConfig={},I.l10ns={en:e({},i),default:e({},i)},I.localize=function(n){I.l10ns.default=e(e({},I.l10ns.default),n)},I.setDefaults=function(n){I.defaultConfig=e(e({},I.defaultConfig),n)},I.parseDate=C({}),I.formatDate=b({}),I.compareDates=M,"undefined"!=typeof jQuery&&void 0!==jQuery.fn&&(jQuery.fn.flatpickr=function(e){return T(this,e)}),Date.prototype.fp_incr=function(e){return new Date(this.getFullYear(),this.getMonth(),this.getDate()+("string"==typeof e?parseInt(e,10):e))},"undefined"!=typeof window&&(window.flatpickr=I),I}));
Index: /navarro-booking-for-woocommerce/trunk/docs/extending.md
===================================================================
--- /navarro-booking-for-woocommerce/trunk/docs/extending.md	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/docs/extending.md	(revision 3750436)
@@ -0,0 +1,116 @@
+# Extending Navarro Booking
+
+Navarro Booking is complete on its own. Add-ons extend it only through the actions and filters
+below, the same way any WordPress plugin is extended. There is no licence check, no
+disabled code path and no "pro" flag anywhere in this plugin.
+
+All instants are UTC strings, `Y-m-d H:i:s`. Calendar dates and wall-clock times are in
+the site timezone (`wp_timezone()`).
+
+## How a booking flows
+
+```
+product page ──GET /wp-json/calentra/v1/availability──▶ Calentra_Availability::get()
+add to cart  ──Calentra_Availability::validate()──▶ Calentra_Bookings::create_hold()   (atomic claim)
+cart/checkout views ─▶ hold extended, or re-claimed if it lapsed and the slot is free
+order placed ─▶ Calentra_Bookings::confirm()   hold → confirmed, or the order fails before payment
+cancelled / failed / refunded / trashed ─▶ Calentra_Bookings::release()
+```
+
+Capacity is counted per **unit**: the slot start for a timeslot booking, and each night
+(local midnight) for a multi-day booking. A claim is a conditional `UPDATE` on that
+unit's counter, so it either takes the seats or affects no row; there is no window in
+which two customers can both take the last seat. `tests/concurrency/run.sh` demonstrates
+this.
+
+### Resources shared across products
+
+A booking on a resource (`resource_id > 0`) is counted twice: on the product's own
+counter, as above, and on the resource's shared counter (inventory rows with
+`product_id = 0`), which every product using that resource draws from. A claim takes the
+resource's seats first and the product's after, all or nothing. So the resource's
+capacity is one pool across all its products, and a product's own capacity on it only
+caps what that product may take.
+
+- The shared counter is keyed on five-minute granules covering the booking, not on slot
+  starts, so products with different slot grids still collide when they overlap
+  (18:00–19:00 and 18:15–19:30). Times off the five-minute grid are rounded outwards,
+  which can refuse two bookings that only touch, never oversell.
+- Multi-day bookings use their nights on the shared counter. A resource must not be
+  shared between a multi-day product and a timeslot product: the two are counted in
+  different units and would not see each other. An extension attaching resources must
+  refuse that combination (Navarro Booking Pro does).
+- A booking on a resource may span at most 400 granules (about 33 hours); a longer one is
+  refused with `calentra_too_long`.
+- Availability on a resource is the lower of what the product has left and what the
+  resource has left across every product.
+- `calentra_slot_capacity` is called with `$product_id = 0` for the shared counter. Return
+  the resource's whole capacity there.
+
+## Filters
+
+| Filter | Arguments | Use |
+|---|---|---|
+| `calentra_slot_capacity` | `int $capacity` (1), `int $product_id`, `int $resource_id`, `string $start_utc` | Seats per slot or night. Raise it for classes, tours, rooms with several beds. With `$product_id = 0` and a resource, it asks for the resource's whole capacity across every product (its shared counter). |
+| `calentra_product_config` | `array $config`, `int $product_id` | Change a product's effective rules: `start`, `end`, `duration`, `blocked_dates`, `blocked_slots`, `max_days_ahead`, `type`. |
+| `calentra_generate_slots` | `array $slots` (`[start, end]` `DateTimeImmutable` pairs, site tz), `int $product_id`, `string $date`, `array $config` | Replace or edit the slot grid for one date — weekly schedules, buffers, per-weekday hours. |
+| `calentra_availability` | `array $result`, `int $product_id`, `array $args` | Post-process computed availability (the REST response body). |
+| `calentra_validate_booking` | `array\|WP_Error $booking`, `int $product_id`, `array $request` | Refuse a booking (return `WP_Error`) or add data to it. Runs on add to cart and checkout. |
+| `calentra_booking_resource_id` | `int $resource_id` (0), `int $product_id`, `array $fields` | Book against a resource (staff member, room) instead of the product. A resource is counted once across every product that offers it (see "Resources shared across products"), and each product's own capacity on it caps that product's share. |
+| `calentra_booking_price` | `float $price`, `array $cart_item`, `WC_Product $product` | Price per unit of quantity. Multi-day defaults to price × nights. Deposits, seasonal pricing. |
+| `calentra_hold_duration` | `int $seconds`, `int $product_id` | How long add-to-cart reserves a slot. Default from settings (60 minutes). |
+| `calentra_ics_email_ids` | `string[] $ids` | Which WooCommerce emails get an `.ics` attachment. |
+| `calentra_claim_resources` | `int[] $resources` (the requested one), `int $product_id`, `array $booking` | Resources to try, in order, for a new claim. Core claims each atomically and the first with room wins; the booking records the winner. Pool staff or rooms with this — never by reading availability and then claiming. Capped at 50. A re-claimed lapsed hold carries `previous_resource_id` in `$booking`. |
+| `calentra_ics_summary` | `string $summary` (item name), `object $booking` | Title of the `.ics` invite. |
+| `calentra_resource_label` | `string $label` ("#id"), `int $resource_id`, `object $booking` | How a resource is named in WooCommerce → Bookings. Core has no resource names of its own. |
+| `calentra_feature_unlocked` | `bool $unlocked` (false), `string $id` | Return true for a feature your extension provides, and its locked teaser disappears everywhere. Ids are the keys of `Calentra_Upgrade::features()`; today only `resources`. |
+| `calentra_upgrade_url` | `string $url` (the `calentra_upgrade_url` option, empty by default) | Where "learn more" on a locked feature goes. While empty, locks explain the feature and show no link. |
+
+## Actions
+
+| Action | Arguments | Fires |
+|---|---|---|
+| `calentra_hold_created` | `int $booking_id`, `array $booking` | A customer added a booking to their cart and the seats were claimed. |
+| `calentra_booking_confirmed` | `int $booking_id`, `int $order_id` | A hold became a confirmed booking on an order (or an admin created one). |
+| `calentra_booking_released` | `int $booking_id`, `string $reason`, `object $row` | Seats were given back: `expired`, `removed_from_cart`, `checkout_failed`, `order_woocommerce_order_status_cancelled`, etc. |
+| `calentra_claim_failed` | `int $product_id`, `array $booking` | A customer lost the race for a slot. |
+| `calentra_product_options_end` | `WP_Post $post` | End of the product's booking options box — add fields here. |
+| `calentra_settings_fields` | — | End of the global settings form — add settings here. |
+
+## Example: a class with 12 places
+
+```php
+add_filter('calentra_slot_capacity', function ($capacity, $product_id) {
+    $places = (int) get_post_meta($product_id, '_my_class_places', true);
+    return $places > 0 ? $places : $capacity;
+}, 10, 2);
+```
+
+Customers can now book quantity > 1, the picker shows remaining places
+(`seats` in the REST response), and the concurrency guarantees hold for any capacity.
+
+## Example: closed on Mondays
+
+```php
+add_filter('calentra_generate_slots', function ($slots, $product_id, $date) {
+    return (new DateTimeImmutable($date))->format('N') === '1' ? [] : $slots;
+}, 10, 3);
+```
+
+## REST
+
+`GET /wp-json/calentra/v1/availability?product_id=ID&from=YYYY-MM-DD&to=YYYY-MM-DD[&resource_id=N]`
+
+Public, no nonce, at most 62 days per request. `resource_id` (default 0, the product
+itself) asks about one resource; it is echoed back as `resource_id` in every response,
+and each resource is cached separately. Core does not check that the resource belongs
+to the product. An extension that assigns resources should do that, for example in
+`calentra_validate_booking`, so that only attached resources can be booked.
+
+The product-page picker sends `resource_id` when the add-to-cart form contains a field
+named `calentra_resource_id` (a `<select>`, say), and reloads availability when it
+changes. Keep that field static markup so cached product pages keep working. Timeslot products return
+`days[date] = [{start, end, local, seats}]` (only bookable slots); multi-day products
+return `nights[date] = seats left`. Responses carry an `ETag` and
+`Cache-Control: public, max-age=30`; a request carrying a WooCommerce cart cookie gets a
+private response in which that cart's own holds count as free.
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-admin.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-admin.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-admin.php	(revision 3750436)
@@ -0,0 +1,516 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+class Calentra_Admin {
+
+    const NONCE_ACTION = 'calentra_save_product';
+    const NONCE_FIELD = 'calentra_product_nonce';
+
+    public function init_hooks() {
+        add_action('add_meta_boxes', [$this, 'add_booking_meta_box']);
+        add_action('save_post_product', [$this, 'save_booking_data'], 10, 2);
+        add_action('admin_notices', [$this, 'show_save_errors']);
+        add_action('admin_menu', [$this, 'register_settings_menu']);
+        add_action('admin_init', [$this, 'register_settings']);
+        add_action('admin_post_calentra_rebuild_inventory', [$this, 'rebuild_inventory']);
+        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
+        add_action('woocommerce_after_order_itemmeta', [$this, 'editable_fields_for_booking'], 10, 3);
+        add_action('woocommerce_before_save_order_items', [$this, 'save_fields_for_booking'], 10, 2);
+        foreach (['calentra_global_start', 'calentra_global_end', 'calentra_global_blocked_dates', 'calentra_global_blocked_slots', 'calentra_max_days_ahead'] as $option) {
+            // The first save of an option goes through add_option(), which fires
+            // add_option_*, not update_option_*, so both are needed.
+            add_action("add_option_{$option}", [__CLASS__, 'bump_global_cache_version']);
+            add_action("update_option_{$option}", [__CLASS__, 'bump_global_cache_version']);
+        }
+    }
+
+    public static function bump_global_cache_version() {
+        update_option('calentra_global_cache_version', (int) get_option('calentra_global_cache_version') + 1);
+    }
+
+    public function enqueue_admin_scripts($hook) {
+        $screen = get_current_screen();
+        $ours = $screen && (
+            $screen->id === 'product'
+            || $screen->id === 'shop_order'
+            || $screen->id === 'woocommerce_page_wc-orders'
+            || $screen->id === 'woocommerce_page_calentra-settings'
+        );
+        if (!$ours) {
+            return;
+        }
+        wp_enqueue_style('flatpickr', CALENTRA_PLUGIN_URL . 'assets/css/flatpickr.min.css', [], calentra_asset_version('assets/css/flatpickr.min.css'));
+        wp_enqueue_script('flatpickr', CALENTRA_PLUGIN_URL . 'assets/js/flatpickr.min.js', [], calentra_asset_version('assets/js/flatpickr.min.js'), true);
+        wp_enqueue_script('calentra-admin-ui', CALENTRA_PLUGIN_URL . 'assets/js/admin-ui.js', ['jquery', 'flatpickr'], calentra_asset_version('assets/js/admin-ui.js'), true);
+
+        // The diagnostics tab has assets of its own, and only that tab needs them.
+        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading which tab is being displayed, not acting on it.
+        if ($screen->id === 'woocommerce_page_calentra-settings' && isset($_GET['tab']) && $_GET['tab'] === 'diagnostics') {
+            Calentra_Diagnostics::enqueue();
+        }
+    }
+
+    // ── Per-product booking meta box ───────────────────────────────────────────
+
+    public function add_booking_meta_box() {
+        add_meta_box(
+            'calentra_booking_meta',
+            __('Navarro Booking Options', 'navarro-booking-for-woocommerce'),
+            [$this, 'render_booking_meta_box'],
+            'product',
+            'normal'
+        );
+    }
+
+    public function render_booking_meta_box($post) {
+        $enabled = get_post_meta($post->ID, '_calentra_booking_enabled', true);
+        $start = get_post_meta($post->ID, '_calentra_start_time', true);
+        $end = get_post_meta($post->ID, '_calentra_end_time', true);
+        $duration = get_post_meta($post->ID, '_calentra_duration', true);
+        $days_ahead = get_post_meta($post->ID, '_calentra_max_days_ahead', true);
+        $blocked_dates = Calentra_Rules::parse_dates(get_post_meta($post->ID, '_calentra_blocked_dates', true))['valid'];
+        $blocked_slots = Calentra_Rules::parse_slots(get_post_meta($post->ID, '_calentra_blocked_slots', true))['valid'];
+
+        wp_nonce_field(self::NONCE_ACTION, self::NONCE_FIELD);
+        ?>
+        <p><label><input type="checkbox" name="calentra_booking_enabled" value="1" <?php checked('1', (string) $enabled); ?>>
+            <?php esc_html_e('Enable Booking', 'navarro-booking-for-woocommerce'); ?></label></p>
+        <?php
+        woocommerce_wp_checkbox([
+            'id'          => '_calentra_hide_quantity',
+            'label'       => __('Hide Quantity Field', 'navarro-booking-for-woocommerce'),
+            'desc_tip'    => true,
+            'description' => __('Hide the quantity selector on the product page for this booking.', 'navarro-booking-for-woocommerce'),
+        ]);
+        woocommerce_wp_select([
+            'id'          => '_calentra_booking_type',
+            'label'       => __('Booking Type', 'navarro-booking-for-woocommerce'),
+            'options'     => [
+                'timeslot'  => __('Timeslot', 'navarro-booking-for-woocommerce'),
+                'multi-day' => __('Multi-Day (priced per night)', 'navarro-booking-for-woocommerce'),
+            ],
+            'description' => __('Time slots within opening hours, or a range of nights from a start date to an end date.', 'navarro-booking-for-woocommerce'),
+            'desc_tip'    => true,
+        ]);
+        ?>
+        <p><label for="calentra_start_time"><?php esc_html_e('Start Time (e.g. 09:00)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="time" id="calentra_start_time" name="calentra_start_time" value="<?php echo esc_attr($start); ?>"></p>
+        <p><label for="calentra_end_time"><?php esc_html_e('End Time (e.g. 17:00)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="time" id="calentra_end_time" name="calentra_end_time" value="<?php echo esc_attr($end); ?>"></p>
+        <?php Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_PRODUCT_TIMES); ?>
+        <p><label for="calentra_duration"><?php esc_html_e('Booking Duration (minutes)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="number" id="calentra_duration" name="calentra_duration" min="1" max="1440" value="<?php echo esc_attr($duration); ?>"></p>
+        <?php Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_PRODUCT_CAPACITY); ?>
+        <p><label for="calentra_max_days_ahead"><?php esc_html_e('Bookable up to (days ahead)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="number" id="calentra_max_days_ahead" name="calentra_max_days_ahead" min="1" max="<?php echo (int) Calentra_Rules::MAX_DAYS_AHEAD; ?>" value="<?php echo esc_attr($days_ahead); ?>"
+                placeholder="<?php echo esc_attr((string) Calentra_Rules::max_days_ahead(0)); ?>">
+            <span class="description"><?php esc_html_e('Leave empty to use the global setting.', 'navarro-booking-for-woocommerce'); ?></span></p>
+        <p><label for="calentra_blocked_dates"><?php esc_html_e('Blocked Dates (YYYY-MM-DD)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="text" id="calentra_blocked_dates" name="calentra_blocked_dates" class="calentra-blocked-dates" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_dates)); ?>"></p>
+        <p><label for="calentra_blocked_slots"><?php esc_html_e('Blocked Time Slots (YYYY-MM-DD HH:MM)', 'navarro-booking-for-woocommerce'); ?></label><br>
+            <input type="text" id="calentra_blocked_slots" name="calentra_blocked_slots" class="calentra-blocked-slots" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_slots)); ?>"></p>
+        <p class="description"><?php
+            /* translators: %s: timezone name */
+            printf(esc_html__('Times are in the site timezone (%s).', 'navarro-booking-for-woocommerce'), esc_html(wp_timezone_string()));
+        ?></p>
+        <?php
+        /**
+         * Fires at the end of the product's booking options, for add-ons to add fields.
+         *
+         * @param WP_Post $post The product.
+         */
+        do_action('calentra_product_options_end', $post);
+        Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_PRODUCT_END);
+    }
+
+    public function save_booking_data($post_id, $post) {
+        if (!isset($_POST[self::NONCE_FIELD])
+            || !wp_verify_nonce(sanitize_key(wp_unslash($_POST[self::NONCE_FIELD])), self::NONCE_ACTION)) {
+            return;
+        }
+        if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
+            return;
+        }
+        if (!current_user_can('edit_post', $post_id)) {
+            return;
+        }
+
+        $post_value = function ($key) {
+            return isset($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
+        };
+        $errors = [];
+
+        update_post_meta($post_id, '_calentra_booking_enabled', $post_value('calentra_booking_enabled') === '1' ? '1' : '0');
+        update_post_meta($post_id, '_calentra_hide_quantity', $post_value('_calentra_hide_quantity') ? 'yes' : 'no');
+        update_post_meta($post_id, '_calentra_booking_type', $post_value('_calentra_booking_type') === 'multi-day' ? 'multi-day' : 'timeslot');
+
+        $start = $post_value('calentra_start_time');
+        $end = $post_value('calentra_end_time');
+        foreach (['_calentra_start_time' => $start, '_calentra_end_time' => $end] as $key => $value) {
+            if ($value !== '' && !Calentra_Rules::valid_time($value)) {
+                /* translators: %s: the invalid value */
+                $errors[] = sprintf(__('"%s" is not a valid time (use HH:MM).', 'navarro-booking-for-woocommerce'), $value);
+                continue;
+            }
+            update_post_meta($post_id, $key, $value);
+        }
+        if ($start && $end && Calentra_Rules::valid_time($start) && Calentra_Rules::valid_time($end) && $end <= $start) {
+            $errors[] = __('The end time must be after the start time; no slots will be offered until this is fixed.', 'navarro-booking-for-woocommerce');
+        }
+
+        $duration = (int) $post_value('calentra_duration');
+        update_post_meta($post_id, '_calentra_duration', $duration > 0 ? min($duration, 1440) : '');
+        $days_ahead = Calentra_Rules::clamp_days_ahead($post_value('calentra_max_days_ahead'));
+        update_post_meta($post_id, '_calentra_max_days_ahead', $days_ahead ?: '');
+
+        $dates = Calentra_Rules::parse_dates($post_value('calentra_blocked_dates'));
+        $slots = Calentra_Rules::parse_slots($post_value('calentra_blocked_slots'));
+        update_post_meta($post_id, '_calentra_blocked_dates', $dates['valid']);
+        update_post_meta($post_id, '_calentra_blocked_slots', $slots['valid']);
+        if ($dates['invalid']) {
+            /* translators: %s: list of rejected entries */
+            $errors[] = sprintf(__('These blocked dates were not understood and were ignored: %s. Use YYYY-MM-DD.', 'navarro-booking-for-woocommerce'), implode(', ', $dates['invalid']));
+        }
+        if ($slots['invalid']) {
+            /* translators: %s: list of rejected entries */
+            $errors[] = sprintf(__('These blocked time slots were not understood and were ignored: %s. Use YYYY-MM-DD HH:MM.', 'navarro-booking-for-woocommerce'), implode(', ', $slots['invalid']));
+        }
+
+        Calentra_Availability::bump_cache_version($post_id);
+        if ($errors) {
+            set_transient('calentra_save_errors_' . get_current_user_id(), $errors, 60);
+        }
+    }
+
+    public function show_save_errors() {
+        $key = 'calentra_save_errors_' . get_current_user_id();
+        $errors = get_transient($key);
+        if (!$errors) {
+            return;
+        }
+        delete_transient($key);
+        echo '<div class="notice notice-error"><p><strong>' . esc_html__('Navarro Booking', 'navarro-booking-for-woocommerce') . '</strong></p><ul>';
+        foreach ((array) $errors as $error) {
+            echo '<li>' . esc_html($error) . '</li>';
+        }
+        echo '</ul></div>';
+    }
+
+    // ── Order item booking editor ─────────────────────────────────────────────
+
+    public function editable_fields_for_booking($item_id, $item, $product) {
+        if (!$item->is_type('line_item') || !current_user_can('edit_shop_orders')) {
+            return;
+        }
+        $booking = Calentra_Bookings::get((int) $item->get_meta('_calentra_booking_id'));
+        $config = Calentra_Rules::product_config($item->get_product_id());
+        if (!$booking && !$config['enabled']) {
+            return;
+        }
+        $type = $booking ? $booking->booking_type : $config['type'];
+        $tz = $booking ? Calentra_Display::tz($booking->site_timezone) : wp_timezone();
+        $start = $booking ? strtotime($booking->start_utc . ' UTC') : 0;
+        $end = $booking ? strtotime($booking->end_utc . ' UTC') : 0;
+
+        echo '<div class="calentra-order-booking edit_booking_fields">';
+        if ($booking) {
+            echo '<p><strong>' . esc_html__('Booking', 'navarro-booking-for-woocommerce') . ' #' . (int) $booking->id . '</strong> — ' . esc_html(ucfirst($booking->status));
+            echo ' · ' . esc_html(Calentra_Display::time_with_zone($start, $tz)) . ' ' . esc_html(wp_date(get_option('date_format'), $start, $tz));
+            echo ' · <code>' . esc_html($booking->start_utc) . ' UTC</code></p>';
+        }
+        woocommerce_wp_text_input([
+            'id'            => "calentra_booking_date_{$item_id}",
+            'name'          => "calentra_booking_date[{$item_id}]",
+            'label'         => __('Booking Date', 'navarro-booking-for-woocommerce'),
+            'value'         => $start ? wp_date('Y-m-d', $start, $tz) : '',
+            'wrapper_class' => 'form-field',
+            'class'         => 'calentra-flatpickr-date',
+        ]);
+        if ($type === 'multi-day') {
+            woocommerce_wp_text_input([
+                'id'            => "calentra_booking_end_date_{$item_id}",
+                'name'          => "calentra_booking_end_date[{$item_id}]",
+                'label'         => __('End Date', 'navarro-booking-for-woocommerce'),
+                'value'         => $end ? wp_date('Y-m-d', $end, $tz) : '',
+                'wrapper_class' => 'form-field',
+                'class'         => 'calentra-flatpickr-date',
+            ]);
+        } else {
+            woocommerce_wp_text_input([
+                'id'            => "calentra_booking_time_{$item_id}",
+                'name'          => "calentra_booking_time[{$item_id}]",
+                /* translators: %s: timezone */
+                'label'         => sprintf(__('Booking Time (%s)', 'navarro-booking-for-woocommerce'), $tz->getName()),
+                'value'         => $start ? wp_date('H:i', $start, $tz) : '',
+                'wrapper_class' => 'form-field',
+                'class'         => 'calentra-flatpickr-time',
+            ]);
+        }
+        echo '</div>';
+    }
+
+    /**
+     * Saves booking edits from the order screen. WooCommerce has already verified its own
+     * nonce on both routes that reach this hook (the order form and the "Save" items AJAX
+     * call); we add the capability check and read only from the items WooCommerce passes.
+     */
+    public function save_fields_for_booking($order_id, $items) {
+        if (!current_user_can('edit_shop_orders') || empty($items['calentra_booking_date']) || !is_array($items['calentra_booking_date'])) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+
+        foreach ($items['calentra_booking_date'] as $item_id => $date) {
+            $item = $order->get_item((int) $item_id);
+            if (!$item || !$item->is_type('line_item')) {
+                continue;
+            }
+            $date = Calentra_Rules::valid_date(sanitize_text_field(wp_unslash($date)));
+            $time = Calentra_Rules::valid_time(sanitize_text_field(wp_unslash($items['calentra_booking_time'][$item_id] ?? '')));
+            $end_date = Calentra_Rules::valid_date(sanitize_text_field(wp_unslash($items['calentra_booking_end_date'][$item_id] ?? '')));
+            if (!$date) {
+                continue;
+            }
+
+            $booking = Calentra_Bookings::get((int) $item->get_meta('_calentra_booking_id'));
+            $config = Calentra_Rules::product_config($item->get_product_id());
+            $type = $booking ? $booking->booking_type : $config['type'];
+            $tz = $booking ? Calentra_Display::tz($booking->site_timezone) : wp_timezone();
+
+            try {
+                if ($type === 'multi-day') {
+                    if (!$end_date || $end_date <= $date) {
+                        continue;
+                    }
+                    $start = new DateTimeImmutable($date, $tz);
+                    $end = new DateTimeImmutable($end_date, $tz);
+                } else {
+                    if (!$time) {
+                        continue;
+                    }
+                    $start = new DateTimeImmutable("$date $time", $tz);
+                    $length = $booking ? strtotime($booking->end_utc) - strtotime($booking->start_utc) : $config['duration'] * 60;
+                    $end = $start->modify('+' . (int) $length . ' seconds');
+                }
+            } catch (Exception $e) {
+                continue;
+            }
+
+            $new = [
+                'type'        => $type,
+                'start_utc'   => gmdate(Calentra_Availability::UTC_FORMAT, $start->getTimestamp()),
+                'end_utc'     => gmdate(Calentra_Availability::UTC_FORMAT, $end->getTimestamp()),
+                'timezone'    => $tz->getName(),
+                'seats'       => max(1, (int) $item->get_quantity()),
+                'resource_id' => $booking ? (int) $booking->resource_id : 0,
+            ];
+            if ($booking && $booking->start_utc === $new['start_utc'] && $booking->end_utc === $new['end_utc']) {
+                continue;
+            }
+
+            // Admins may book outside opening hours, but never over capacity.
+            if ($booking) {
+                $result = Calentra_Bookings::move($booking->id, $new);
+            } else {
+                $result = Calentra_Bookings::create_confirmed($item->get_product_id(), $new, $order_id, $item->get_id());
+                if (!is_wp_error($result)) {
+                    $item->update_meta_data('_calentra_booking_id', $result);
+                }
+            }
+
+            if (is_wp_error($result)) {
+                $order->add_order_note(sprintf(
+                    /* translators: 1: product name, 2: error */
+                    __('Navarro Booking: booking for %1$s was not changed. %2$s', 'navarro-booking-for-woocommerce'),
+                    $item->get_name(),
+                    $result->get_error_message()
+                ));
+                continue;
+            }
+            Calentra_Display::write_item_meta($item, $new);
+            $item->save();
+            $order->add_order_note(sprintf(
+                /* translators: 1: product name, 2: new date and time */
+                __('Navarro Booking: booking for %1$s moved to %2$s.', 'navarro-booking-for-woocommerce'),
+                $item->get_name(),
+                implode(' ', Calentra_Display::lines($new))
+            ));
+        }
+    }
+
+    // ── Global settings + diagnostics ─────────────────────────────────────────
+
+    public function register_settings_menu() {
+        add_submenu_page(
+            'woocommerce',
+            __('Bookings', 'navarro-booking-for-woocommerce'),
+            __('Bookings', 'navarro-booking-for-woocommerce'),
+            'manage_woocommerce',
+            'calentra-bookings',
+            function () {
+                // Loaded only here: WP_List_Table has no business on any other request.
+                require_once CALENTRA_PLUGIN_PATH . 'includes/class-calentra-bookings-list.php';
+                Calentra_Bookings_List::render_page();
+            }
+        );
+        add_submenu_page(
+            'woocommerce',
+            __('Navarro Booking Settings', 'navarro-booking-for-woocommerce'),
+            __('Navarro Booking Settings', 'navarro-booking-for-woocommerce'),
+            'manage_woocommerce',
+            'calentra-settings',
+            [$this, 'render_settings_page']
+        );
+    }
+
+    public function register_settings() {
+        register_setting('calentra_settings', 'calentra_global_start', [
+            'type'              => 'string',
+            'sanitize_callback' => function ($v) {
+                return $this->sanitize_time_setting($v, 'calentra_global_start', '09:00');
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_global_end', [
+            'type'              => 'string',
+            'sanitize_callback' => function ($v) {
+                return $this->sanitize_time_setting($v, 'calentra_global_end', '17:00');
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_global_blocked_dates', [
+            'type'              => 'array',
+            'sanitize_callback' => function ($v) {
+                $parsed = Calentra_Rules::parse_dates($v);
+                if ($parsed['invalid']) {
+                    /* translators: %s: rejected entries */
+                    add_settings_error('calentra_global_blocked_dates', 'invalid', sprintf(__('These blocked dates were not understood and were ignored: %s. Use YYYY-MM-DD.', 'navarro-booking-for-woocommerce'), implode(', ', $parsed['invalid'])));
+                }
+                return $parsed['valid'];
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_global_blocked_slots', [
+            'type'              => 'array',
+            'sanitize_callback' => function ($v) {
+                $parsed = Calentra_Rules::parse_slots($v);
+                if ($parsed['invalid']) {
+                    /* translators: %s: rejected entries */
+                    add_settings_error('calentra_global_blocked_slots', 'invalid', sprintf(__('These blocked time slots were not understood and were ignored: %s. Use YYYY-MM-DD HH:MM.', 'navarro-booking-for-woocommerce'), implode(', ', $parsed['invalid'])));
+                }
+                return $parsed['valid'];
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_hold_minutes', [
+            'type'              => 'integer',
+            'default'           => 60,
+            'sanitize_callback' => function ($v) {
+                return min(1440, max(5, absint($v) ?: 60));
+            },
+        ]);
+        register_setting('calentra_settings', 'calentra_max_days_ahead', [
+            'type'              => 'integer',
+            'default'           => Calentra_Rules::DEFAULT_DAYS_AHEAD,
+            'sanitize_callback' => function ($v) {
+                return Calentra_Rules::clamp_days_ahead($v) ?: Calentra_Rules::DEFAULT_DAYS_AHEAD;
+            },
+        ]);
+    }
+
+    private function sanitize_time_setting($value, $option, $fallback) {
+        $value = sanitize_text_field((string) $value);
+        if (Calentra_Rules::valid_time($value)) {
+            return $value;
+        }
+        /* translators: %s: the invalid value */
+        add_settings_error($option, 'invalid', sprintf(__('"%s" is not a valid time (use HH:MM).', 'navarro-booking-for-woocommerce'), $value));
+        return get_option($option, $fallback);
+    }
+
+    public function render_settings_page() {
+        if (!current_user_can('manage_woocommerce')) {
+            return;
+        }
+        $tab = isset($_GET['tab']) && $_GET['tab'] === 'diagnostics' ? 'diagnostics' : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+        $base = admin_url('admin.php?page=calentra-settings');
+        ?>
+        <div class="wrap">
+            <h1><?php esc_html_e('Navarro Booking', 'navarro-booking-for-woocommerce'); ?></h1>
+            <nav class="nav-tab-wrapper">
+                <a href="<?php echo esc_url($base); ?>" class="nav-tab <?php echo $tab === 'settings' ? 'nav-tab-active' : ''; ?>"><?php esc_html_e('Settings', 'navarro-booking-for-woocommerce'); ?></a>
+                <a href="<?php echo esc_url(add_query_arg('tab', 'diagnostics', $base)); ?>" class="nav-tab <?php echo $tab === 'diagnostics' ? 'nav-tab-active' : ''; ?>"><?php esc_html_e('Timezone diagnostics', 'navarro-booking-for-woocommerce'); ?></a>
+            </nav>
+            <?php
+            if ($tab === 'diagnostics') {
+                (new Calentra_Diagnostics())->render();
+            } else {
+                $this->render_settings_form();
+            }
+            ?>
+        </div>
+        <?php
+    }
+
+    private function render_settings_form() {
+        $blocked_dates = Calentra_Rules::parse_dates(get_option('calentra_global_blocked_dates', []))['valid'];
+        $blocked_slots = Calentra_Rules::parse_slots(get_option('calentra_global_blocked_slots', []))['valid'];
+        settings_errors();
+        ?>
+        <form method="post" action="options.php">
+            <?php settings_fields('calentra_settings'); ?>
+            <table class="form-table" role="presentation">
+                <tr>
+                    <th scope="row"><label for="calentra_global_start"><?php esc_html_e('Global Start Time', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="time" id="calentra_global_start" name="calentra_global_start" value="<?php echo esc_attr(get_option('calentra_global_start', '09:00')); ?>"></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_global_end"><?php esc_html_e('Global End Time', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="time" id="calentra_global_end" name="calentra_global_end" value="<?php echo esc_attr(get_option('calentra_global_end', '17:00')); ?>"></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_global_blocked_dates"><?php esc_html_e('Blocked Dates', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="text" id="calentra_global_blocked_dates" name="calentra_global_blocked_dates" class="calentra-blocked-dates" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_dates)); ?>">
+                        <p class="description"><?php esc_html_e('Comma-separated YYYY-MM-DD', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_global_blocked_slots"><?php esc_html_e('Blocked Time Slots', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="text" id="calentra_global_blocked_slots" name="calentra_global_blocked_slots" class="calentra-blocked-slots" style="width:100%" value="<?php echo esc_attr(implode(', ', $blocked_slots)); ?>">
+                        <p class="description"><?php esc_html_e('Comma-separated YYYY-MM-DD HH:MM', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_hold_minutes"><?php esc_html_e('Cart hold (minutes)', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="number" min="5" max="1440" id="calentra_hold_minutes" name="calentra_hold_minutes" value="<?php echo esc_attr((int) get_option('calentra_hold_minutes', 60)); ?>">
+                        <p class="description"><?php esc_html_e('How long a slot stays reserved for a customer after they add it to their cart. The hold is refreshed while they are active in the cart or checkout. If it lapses and someone else books the slot, checkout is refused before payment.', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+                <tr>
+                    <th scope="row"><label for="calentra_max_days_ahead"><?php esc_html_e('Bookable up to (days ahead)', 'navarro-booking-for-woocommerce'); ?></label></th>
+                    <td><input type="number" min="1" max="<?php echo (int) Calentra_Rules::MAX_DAYS_AHEAD; ?>" id="calentra_max_days_ahead" name="calentra_max_days_ahead" value="<?php echo esc_attr((string) Calentra_Rules::max_days_ahead(0)); ?>">
+                        <p class="description"><?php esc_html_e('How far ahead customers can book. Products can set their own limit.', 'navarro-booking-for-woocommerce'); ?></p></td>
+                </tr>
+            </table>
+            <p class="description"><?php
+                /* translators: %s: timezone name */
+                printf(esc_html__('All times are in the site timezone (%s), set under Settings → General.', 'navarro-booking-for-woocommerce'), esc_html(wp_timezone_string()));
+            ?></p>
+            <?php
+            Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_SETTINGS);
+            /** Fires after the built-in global settings, for add-ons to add their own. */
+            do_action('calentra_settings_fields');
+            submit_button();
+            ?>
+        </form>
+        <?php
+        Calentra_Upgrade::settings_footer();
+    }
+
+    public function rebuild_inventory() {
+        if (!current_user_can('manage_woocommerce')) {
+            wp_die(esc_html__('You do not have permission to do that.', 'navarro-booking-for-woocommerce'));
+        }
+        check_admin_referer('calentra_rebuild_inventory');
+        Calentra_Install::rebuild_inventory();
+        wp_safe_redirect(add_query_arg(['page' => 'calentra-settings', 'tab' => 'diagnostics', 'rebuilt' => 1], admin_url('admin.php')));
+        exit;
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-availability.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-availability.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-availability.php	(revision 3750436)
@@ -0,0 +1,447 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * The single availability authority.
+ *
+ * The REST read endpoint, add-to-cart validation and checkout re-validation all call
+ * this class, so a slot that the picker would not offer can never be bought.
+ *
+ * All instants are UTC ("Y-m-d H:i:s"). Calendar dates and wall-clock times are in the
+ * site timezone, because that is the timezone the merchant configures opening hours in.
+ */
+class Calentra_Availability {
+
+    const UTC_FORMAT = 'Y-m-d H:i:s';
+    const MAX_RANGE_DAYS = 62;
+
+    /**
+     * Seats per bookable unit. 1 in this plugin; an add-on may raise it.
+     *
+     * @param int    $product_id
+     * @param int    $resource_id
+     * @param string $start_utc   Unit start, UTC.
+     */
+    public static function capacity($product_id, $resource_id = 0, $start_utc = '') {
+        /**
+         * Filters how many seats one slot (or one night, for multi-day) holds.
+         *
+         * @param int    $capacity    Default 1.
+         * @param int    $product_id  Product ID.
+         * @param int    $resource_id Resource ID, 0 for the product itself.
+         * @param string $start_utc   The unit's start instant in UTC.
+         */
+        return max(1, (int) apply_filters('calentra_slot_capacity', 1, (int) $product_id, (int) $resource_id, $start_utc));
+    }
+
+    /**
+     * Available slots or nights for a product over a date range (site dates, inclusive).
+     *
+     * @param int    $product_id
+     * @param string $from        Y-m-d, site timezone.
+     * @param string $to          Y-m-d, site timezone.
+     * @param array  $args        session_key: holds belonging to this cart session count as free.
+     *                            exclude_booking: a booking ID to ignore (when moving it).
+     *                            resource_id: defaults to 0.
+     * @return array|WP_Error
+     */
+    public static function get($product_id, $from, $to, array $args = []) {
+        $args = wp_parse_args($args, ['session_key' => '', 'exclude_booking' => 0, 'resource_id' => 0]);
+        $product_id = (int) $product_id;
+        $config = Calentra_Rules::product_config($product_id);
+
+        if (!$config['enabled']) {
+            return new WP_Error('calentra_not_bookable', __('This product cannot be booked.', 'navarro-booking-for-woocommerce'), ['status' => 404]);
+        }
+        $from = Calentra_Rules::valid_date($from);
+        $to = Calentra_Rules::valid_date($to);
+        if (!$from || !$to || $to < $from) {
+            return new WP_Error('calentra_bad_range', __('Invalid date range.', 'navarro-booking-for-woocommerce'), ['status' => 400]);
+        }
+
+        $tz = $config['timezone'];
+        $first = new DateTimeImmutable($from, $tz);
+        $last = new DateTimeImmutable($to, $tz);
+        if ($first->diff($last)->days > self::MAX_RANGE_DAYS) {
+            return new WP_Error('calentra_bad_range', __('Date range too long.', 'navarro-booking-for-woocommerce'), ['status' => 400]);
+        }
+
+        $range_start = $first->setTime(0, 0);
+        $range_end = $last->modify('+1 day')->setTime(0, 0);
+        $busy = self::busy_intervals($product_id, (int) $args['resource_id'], $range_start, $range_end, $args);
+        // On a resource, also what every product has booked on it: the resource's own
+        // capacity is a pool they share. Null for the product itself.
+        $resource_busy = (int) $args['resource_id'] > 0
+            ? self::resource_busy_intervals((int) $args['resource_id'], $range_start, $range_end, $args)
+            : null;
+
+        $result = [
+            'product_id'  => $product_id,
+            'resource_id' => (int) $args['resource_id'],
+            'type'        => $config['type'],
+            'timezone'    => $tz->getName(),
+            'from'        => $from,
+            'to'          => $to,
+        ];
+
+        if ($config['type'] === 'multi-day') {
+            $result['nights'] = [];
+            for ($day = $range_start; $day < $range_end; $day = $day->modify('+1 day')) {
+                $date = $day->format('Y-m-d');
+                $result['nights'][$date] = self::night_seats($product_id, (int) $args['resource_id'], $date, $config, $busy, $resource_busy);
+            }
+        } else {
+            $result['days'] = [];
+            for ($day = $range_start; $day < $range_end; $day = $day->modify('+1 day')) {
+                $date = $day->format('Y-m-d');
+                $result['days'][$date] = self::day_slots($product_id, (int) $args['resource_id'], $date, $config, $busy, $resource_busy);
+            }
+        }
+
+        /**
+         * Filters the computed availability for a product and range.
+         *
+         * @param array $result     Availability, as returned by the REST endpoint.
+         * @param int   $product_id Product ID.
+         * @param array $args       Lookup arguments.
+         */
+        return apply_filters('calentra_availability', $result, $product_id, $args);
+    }
+
+    /**
+     * Slots on one site-local date, each with remaining seats. Only slots with at least
+     * one free seat are returned.
+     */
+    private static function day_slots($product_id, $resource_id, $date, array $config, array $busy, $resource_busy = null) {
+        $out = [];
+        if (!self::date_is_open($date, $config)) {
+            return $out;
+        }
+
+        $now = time();
+        $blocked = self::blocked_ranges_for($date, $config);
+
+        foreach (self::generate_slots($product_id, $date, $config) as $slot) {
+            [$start, $end] = $slot;
+            if ($start->getTimestamp() <= $now) {
+                continue;
+            }
+            foreach ($blocked as [$bs, $be]) {
+                if ($start->getTimestamp() < $be && $bs < $end->getTimestamp()) {
+                    continue 2;
+                }
+            }
+            $start_utc = gmdate(self::UTC_FORMAT, $start->getTimestamp());
+            $seats = self::seats_left($product_id, $resource_id, $start_utc, $start->getTimestamp(), $end->getTimestamp(), $busy, $resource_busy);
+            if ($seats < 1) {
+                continue;
+            }
+            $out[] = [
+                'start' => gmdate('Y-m-d\TH:i:s\Z', $start->getTimestamp()),
+                'end'   => gmdate('Y-m-d\TH:i:s\Z', $end->getTimestamp()),
+                'local' => $start->format('H:i'),
+                'seats' => $seats,
+            ];
+        }
+        return $out;
+    }
+
+    /**
+     * Wall-clock slot grid for one date: [start, end) pairs as site-timezone DateTimes.
+     *
+     * @return array<int, array{0: DateTimeImmutable, 1: DateTimeImmutable}>
+     */
+    public static function generate_slots($product_id, $date, array $config) {
+        $tz = $config['timezone'];
+        [$sh, $sm] = array_map('intval', explode(':', $config['start']));
+        [$eh, $em] = array_map('intval', explode(':', $config['end']));
+        $day = new DateTimeImmutable($date, $tz);
+        $open = $sh * 60 + $sm;
+        $close = $eh * 60 + $em;
+        $slots = [];
+        $seen = [];
+
+        for ($m = $open; $m + $config['duration'] <= $close; $m += $config['duration']) {
+            // Built from wall-clock time, so a 09:00 class is 09:00 on both sides of a DST change.
+            $start = $day->setTime(intdiv($m, 60), $m % 60);
+            $end = $start->modify('+' . $config['duration'] . ' minutes');
+            if (isset($seen[$start->getTimestamp()])) {
+                continue; // A wall time skipped by a DST jump normalises onto its neighbour.
+            }
+            $seen[$start->getTimestamp()] = true;
+            $slots[] = [$start, $end];
+        }
+
+        /**
+         * Filters the slot grid for one date before availability is applied.
+         *
+         * @param array  $slots      [start, end] DateTimeImmutable pairs in the site timezone.
+         * @param int    $product_id Product ID.
+         * @param string $date       Site-local Y-m-d.
+         * @param array  $config     Product configuration.
+         */
+        return apply_filters('calentra_generate_slots', $slots, (int) $product_id, $date, $config);
+    }
+
+    private static function night_seats($product_id, $resource_id, $date, array $config, array $busy, $resource_busy = null) {
+        if (!self::date_is_open($date, $config)) {
+            return 0;
+        }
+        $tz = $config['timezone'];
+        $start = new DateTimeImmutable($date, $tz);
+        $end = $start->modify('+1 day');
+        $start_utc = gmdate(self::UTC_FORMAT, $start->getTimestamp());
+        $seats = self::seats_left($product_id, $resource_id, $start_utc, $start->getTimestamp(), $end->getTimestamp(), $busy, $resource_busy);
+        return max(0, $seats);
+    }
+
+    private static function date_is_open($date, array $config) {
+        $today = (new DateTimeImmutable('now', $config['timezone']))->format('Y-m-d');
+        if ($date < $today) {
+            return false;
+        }
+        $horizon = (new DateTimeImmutable('now', $config['timezone']))->modify('+' . (int) $config['max_days_ahead'] . ' days')->format('Y-m-d');
+        if ($date > $horizon) {
+            return false;
+        }
+        return !in_array($date, $config['blocked_dates'], true);
+    }
+
+    /**
+     * Blocked "YYYY-MM-DD HH:MM" entries for a date, as [start, end) timestamps.
+     * Each blocks one slot length from the given time, as in v1.1.0.
+     */
+    private static function blocked_ranges_for($date, array $config) {
+        $ranges = [];
+        foreach ($config['blocked_slots'] as $slot) {
+            if (strpos($slot, $date . ' ') !== 0) {
+                continue;
+            }
+            $start = new DateTimeImmutable($slot, $config['timezone']);
+            $ranges[] = [$start->getTimestamp(), $start->getTimestamp() + $config['duration'] * 60];
+        }
+        return $ranges;
+    }
+
+    /**
+     * Bookings that occupy seats in a range: confirmed ones and holds that have not
+     * expired. An expired hold stops counting the moment its timestamp passes, so
+     * correctness never waits on a cron job.
+     *
+     * @return array<int, array{0: int, 1: int, 2: int}> [start ts, end ts, seats]
+     */
+    private static function busy_intervals($product_id, $resource_id, DateTimeImmutable $from, DateTimeImmutable $to, array $args) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(self::UTC_FORMAT);
+
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Navarro Booking's own table; core has no API for it, and a cached read could sell a seat twice.
+        $rows = $wpdb->get_results($wpdb->prepare(
+            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is Calentra_Install::bookings_table(), a prefix plus a literal; every value below is a placeholder.
+            "SELECT id, start_utc, end_utc, seats, status, session_key FROM $table
+              WHERE product_id = %d AND resource_id = %d
+                AND start_utc < %s AND end_utc > %s
+                AND (status = 'confirmed' OR (status = 'hold' AND hold_expires >= %s))",
+            $product_id,
+            $resource_id,
+            gmdate(self::UTC_FORMAT, $to->getTimestamp()),
+            gmdate(self::UTC_FORMAT, $from->getTimestamp()),
+            $now
+        ));
+
+        $busy = [];
+        foreach ((array) $rows as $row) {
+            if ((int) $row->id === (int) $args['exclude_booking']) {
+                continue;
+            }
+            if ($row->status === 'hold' && $args['session_key'] !== '' && $row->session_key === $args['session_key']) {
+                continue; // A customer is never blocked by their own cart.
+            }
+            $busy[] = [strtotime($row->start_utc . ' UTC'), strtotime($row->end_utc . ' UTC'), (int) $row->seats];
+        }
+        return $busy;
+    }
+
+    /**
+     * Bookings on a resource from every product, as busy_intervals() returns them, with
+     * each interval rounded out to Calentra_Bookings::RESOURCE_GRANULE.
+     *
+     * @return array<int, array{0: int, 1: int, 2: int}>
+     */
+    private static function resource_busy_intervals($resource_id, DateTimeImmutable $from, DateTimeImmutable $to, array $args) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(self::UTC_FORMAT);
+        $granule = Calentra_Bookings::RESOURCE_GRANULE;
+
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Navarro Booking's own table; core has no API for it, and a cached read could sell a seat twice.
+        $rows = $wpdb->get_results($wpdb->prepare(
+            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is Calentra_Install::bookings_table(), a prefix plus a literal; every value below is a placeholder.
+            "SELECT id, start_utc, end_utc, seats, status, session_key FROM $table
+              WHERE resource_id = %d
+                AND start_utc < %s AND end_utc > %s
+                AND (status = 'confirmed' OR (status = 'hold' AND hold_expires >= %s))",
+            $resource_id,
+            gmdate(self::UTC_FORMAT, $to->getTimestamp() + $granule),
+            gmdate(self::UTC_FORMAT, $from->getTimestamp() - $granule),
+            $now
+        ));
+
+        $busy = [];
+        foreach ((array) $rows as $row) {
+            if ((int) $row->id === (int) $args['exclude_booking']) {
+                continue;
+            }
+            if ($row->status === 'hold' && $args['session_key'] !== '' && $row->session_key === $args['session_key']) {
+                continue; // A customer is never blocked by their own cart.
+            }
+            $start = strtotime($row->start_utc . ' UTC');
+            $end = strtotime($row->end_utc . ' UTC');
+            $busy[] = [intdiv($start, $granule) * $granule, intdiv($end + $granule - 1, $granule) * $granule, (int) $row->seats];
+        }
+        return $busy;
+    }
+
+    /**
+     * Seats left for [start, end): what the product may still take, and on a resource no
+     * more than the resource has left across every product.
+     *
+     * Both sum every booking that overlaps the interval rather than the peak in use at
+     * any one moment, so two bookings that overlap the slot but not each other both
+     * count. That understates what is free, never overstates it, as per-product
+     * counting always has.
+     *
+     * The resource-wide part rounds intervals out to Calentra_Bookings::RESOURCE_GRANULE,
+     * as the claim does, so a slot shown as free is not refused for sharing a granule.
+     */
+    private static function seats_left($product_id, $resource_id, $start_utc, $start, $end, array $busy, $resource_busy) {
+        $seats = self::capacity($product_id, $resource_id, $start_utc) - self::seats_overlapping($busy, $start, $end);
+        if ($resource_busy !== null) {
+            $granule = Calentra_Bookings::RESOURCE_GRANULE;
+            $pool = self::capacity(0, $resource_id, $start_utc) - self::seats_overlapping(
+                $resource_busy,
+                intdiv($start, $granule) * $granule,
+                intdiv($end + $granule - 1, $granule) * $granule
+            );
+            $seats = min($seats, $pool);
+        }
+        return $seats;
+    }
+
+    private static function seats_overlapping(array $busy, $start, $end) {
+        $used = 0;
+        foreach ($busy as [$bs, $be, $seats]) {
+            if ($bs < $end && $start < $be) {
+                $used += $seats;
+            }
+        }
+        return $used;
+    }
+
+    /**
+     * Checks a customer's requested booking against the same rules the picker uses.
+     *
+     * @param int   $product_id
+     * @param array $request    date (Y-m-d), time (H:i, timeslot), end_date (Y-m-d, multi-day), seats.
+     * @param array $args       As for get().
+     * @return array|WP_Error   On success: type, start_utc, end_utc, seats, resource_id, date, time, end_date.
+     */
+    public static function validate($product_id, array $request, array $args = []) {
+        $product_id = (int) $product_id;
+        $config = Calentra_Rules::product_config($product_id);
+        $seats = max(1, (int) ($request['seats'] ?? 1));
+        $resource_id = (int) ($args['resource_id'] ?? 0);
+        $date = Calentra_Rules::valid_date($request['date'] ?? '');
+
+        if (!$config['enabled']) {
+            return new WP_Error('calentra_not_bookable', __('This product cannot be booked.', 'navarro-booking-for-woocommerce'));
+        }
+        if (!$date) {
+            return new WP_Error('calentra_bad_date', __('Please choose a valid booking date.', 'navarro-booking-for-woocommerce'));
+        }
+
+        if ($config['type'] === 'multi-day') {
+            $end_date = Calentra_Rules::valid_date($request['end_date'] ?? '');
+            if (!$end_date || $end_date <= $date) {
+                return new WP_Error('calentra_bad_end_date', __('Please choose an end date after the start date.', 'navarro-booking-for-woocommerce'));
+            }
+            $last_night = (new DateTimeImmutable($end_date, $config['timezone']))->modify('-1 day')->format('Y-m-d');
+            $avail = self::get($product_id, $date, $last_night, $args);
+            if (is_wp_error($avail)) {
+                return $avail;
+            }
+            foreach ($avail['nights'] as $night => $free) {
+                if ($free < $seats) {
+                    return new WP_Error('calentra_unavailable', sprintf(
+                        /* translators: %s: a date */
+                        __('Sorry, %s is not available for the dates you chose. Please pick different dates.', 'navarro-booking-for-woocommerce'),
+                        $night
+                    ));
+                }
+            }
+            $start = new DateTimeImmutable($date, $config['timezone']);
+            $end = new DateTimeImmutable($end_date, $config['timezone']);
+            $booking = [
+                'type'      => 'multi-day',
+                'start_utc' => gmdate(self::UTC_FORMAT, $start->getTimestamp()),
+                'end_utc'   => gmdate(self::UTC_FORMAT, $end->getTimestamp()),
+                'date'      => $date,
+                'time'      => '',
+                'end_date'  => $end_date,
+            ];
+        } else {
+            $time = Calentra_Rules::valid_time($request['time'] ?? '');
+            if (!$time) {
+                return new WP_Error('calentra_bad_time', __('Please choose a time slot.', 'navarro-booking-for-woocommerce'));
+            }
+            $avail = self::get($product_id, $date, $date, $args);
+            if (is_wp_error($avail)) {
+                return $avail;
+            }
+            $match = null;
+            foreach ($avail['days'][$date] as $slot) {
+                if ($slot['local'] === $time) {
+                    $match = $slot;
+                    break;
+                }
+            }
+            if (!$match || $match['seats'] < $seats) {
+                return new WP_Error('calentra_unavailable', __('Sorry, that time slot is not available. Please choose another.', 'navarro-booking-for-woocommerce'));
+            }
+            $booking = [
+                'type'      => 'timeslot',
+                'start_utc' => gmdate(self::UTC_FORMAT, strtotime($match['start'])),
+                'end_utc'   => gmdate(self::UTC_FORMAT, strtotime($match['end'])),
+                'date'      => $date,
+                'time'      => $time,
+                'end_date'  => '',
+            ];
+        }
+
+        $booking['seats'] = $seats;
+        $booking['resource_id'] = $resource_id;
+        $booking['timezone'] = $config['timezone']->getName();
+
+        /**
+         * Filters the result of validating a booking request. Return a WP_Error to refuse it.
+         *
+         * @param array|WP_Error $booking    The normalised booking.
+         * @param int            $product_id Product ID.
+         * @param array          $request    The raw request.
+         */
+        return apply_filters('calentra_validate_booking', $booking, $product_id, $request);
+    }
+
+    /**
+     * Per-product cache version. Bumped whenever a booking or the product's rules change,
+     * so cached availability responses are invalidated.
+     */
+    public static function cache_version($product_id) {
+        return (int) get_post_meta($product_id, '_calentra_cache_version', true);
+    }
+
+    public static function bump_cache_version($product_id) {
+        update_post_meta($product_id, '_calentra_cache_version', self::cache_version($product_id) + 1);
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-bookings-list.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-bookings-list.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-bookings-list.php	(revision 3750436)
@@ -0,0 +1,308 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+if (!class_exists('WP_List_Table')) {
+    require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
+}
+
+/*
+ * The list reads Navarro Booking's own bookings table, which WordPress has no API for, so the
+ * direct-query and caching sniffs cannot be satisfied in this file. They are switched
+ * off for the file on the same invariant used in class-calentra-bookings.php:
+ *
+ *   the only table name is Calentra_Install::bookings_table() ($wpdb->prefix plus a
+ *   literal), passed as a %i identifier; every filter value is a %d or %s placeholder
+ *   passed through $wpdb->prepare(); ORDER BY takes only a column from a fixed
+ *   allow-list.
+ *
+ * The list is uncached because it is an admin screen that must show the current state.
+ */
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+/**
+ * Read-only list of bookings, under WooCommerce → Bookings.
+ *
+ * Filters arrive by GET and only narrow what is shown; nothing on this screen writes,
+ * so there is no nonce to check.
+ */
+class Calentra_Bookings_List extends WP_List_Table {
+
+    const PER_PAGE = 20;
+
+    const STATUSES = ['active', 'confirmed', 'hold', 'cancelled', 'expired', 'all'];
+
+    const SORTABLE = ['id' => 'id', 'start' => 'start_utc', 'product' => 'product_id'];
+
+    public function __construct() {
+        parent::__construct([
+            'singular' => 'calentra-booking',
+            'plural'   => 'calentra-bookings',
+            'ajax'     => false,
+        ]);
+    }
+
+    /**
+     * Current filters, read from the query string and normalised.
+     *
+     * @return array{product: int, status: string, from: string, to: string}
+     */
+    public static function filters() {
+        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only filters on a read-only screen.
+        $get = function ($key) {
+            return isset($_GET[$key]) ? sanitize_text_field(wp_unslash($_GET[$key])) : null;
+        };
+        $status = $get('booking_status');
+        $from = $get('from');
+        // phpcs:enable WordPress.Security.NonceVerification.Recommended
+        return [
+            'product' => absint($get('product')),
+            'status'  => in_array($status, self::STATUSES, true) ? $status : 'active',
+            // With no "from", the list starts today: upcoming bookings are what people look for.
+            'from'    => $from === null ? wp_date('Y-m-d') : Calentra_Rules::valid_date($from),
+            'to'      => Calentra_Rules::valid_date((string) $get('to')),
+        ];
+    }
+
+    public function get_columns() {
+        return [
+            'id'       => __('Booking', 'navarro-booking-for-woocommerce'),
+            'product'  => __('Product', 'navarro-booking-for-woocommerce'),
+            'resource' => __('Resource', 'navarro-booking-for-woocommerce'),
+            'start'    => __('Start', 'navarro-booking-for-woocommerce'),
+            'end'      => __('End', 'navarro-booking-for-woocommerce'),
+            'seats'    => __('Seats', 'navarro-booking-for-woocommerce'),
+            'status'   => __('Status', 'navarro-booking-for-woocommerce'),
+            'order'    => __('Order', 'navarro-booking-for-woocommerce'),
+        ];
+    }
+
+    protected function get_sortable_columns() {
+        return [
+            'id'      => ['id', false],
+            'start'   => ['start', false],
+            'product' => ['product', false],
+        ];
+    }
+
+    protected function get_primary_column_name() {
+        return 'start';
+    }
+
+    public function no_items() {
+        esc_html_e('No bookings match these filters.', 'navarro-booking-for-woocommerce');
+    }
+
+    public function prepare_items() {
+        global $wpdb;
+        $f = self::filters();
+        $tz = wp_timezone();
+        $where = ['1=1'];
+        $params = [];
+
+        if ($f['product']) {
+            $where[] = 'product_id = %d';
+            $params[] = $f['product'];
+        }
+        if ($f['status'] === 'active') {
+            // What occupies seats right now: confirmed bookings and holds that haven't lapsed.
+            $where[] = "(status = 'confirmed' OR (status = 'hold' AND hold_expires >= %s))";
+            $params[] = gmdate(Calentra_Availability::UTC_FORMAT);
+        } elseif ($f['status'] !== 'all') {
+            $where[] = 'status = %s';
+            $params[] = $f['status'];
+        }
+        if ($f['from']) {
+            $where[] = 'end_utc > %s';
+            $params[] = gmdate(Calentra_Availability::UTC_FORMAT, (new DateTimeImmutable($f['from'], $tz))->getTimestamp());
+        }
+        if ($f['to']) {
+            $where[] = 'start_utc < %s';
+            $params[] = gmdate(Calentra_Availability::UTC_FORMAT, (new DateTimeImmutable($f['to'], $tz))->modify('+1 day')->getTimestamp());
+        }
+
+        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- sorting a read-only list.
+        $orderby_key = isset($_GET['orderby']) ? sanitize_key(wp_unslash($_GET['orderby'])) : 'start';
+        $order = isset($_GET['order']) && strtolower(sanitize_key(wp_unslash($_GET['order']))) === 'desc' ? 'DESC' : 'ASC';
+        // phpcs:enable WordPress.Security.NonceVerification.Recommended
+        $orderby = self::SORTABLE[$orderby_key] ?? 'start_utc';
+
+        $page = $this->get_pagenum();
+        $where_sql = implode(' AND ', $where);
+        $table = Calentra_Install::bookings_table();
+
+        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where_sql is built only from the literal fragments above; $orderby and $order come from allow-lists.
+        // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- the sniff cannot see into $where_sql or count array_merge(); at runtime the count matches, because each $where fragment adds exactly one placeholder and one entry to $params.
+        $total = (int) $wpdb->get_var($wpdb->prepare(
+            "SELECT COUNT(*) FROM %i WHERE $where_sql",
+            array_merge([$table], $params)
+        ));
+        $this->items = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM %i WHERE $where_sql ORDER BY $orderby $order, id ASC LIMIT %d OFFSET %d",
+            array_merge([$table], $params, [self::PER_PAGE, ($page - 1) * self::PER_PAGE])
+        ));
+        // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+        $this->_column_headers = [$this->get_columns(), [], $this->get_sortable_columns(), $this->get_primary_column_name()];
+        $this->set_pagination_args([
+            'total_items' => $total,
+            'per_page'    => self::PER_PAGE,
+            'total_pages' => (int) ceil($total / self::PER_PAGE),
+        ]);
+    }
+
+    /**
+     * Products that have at least one booking, for the filter dropdown.
+     */
+    private static function booked_products() {
+        global $wpdb;
+        $ids = $wpdb->get_col($wpdb->prepare('SELECT DISTINCT product_id FROM %i', Calentra_Install::bookings_table()));
+        $out = [];
+        foreach ($ids as $id) {
+            $out[(int) $id] = get_the_title($id) ?: sprintf(
+                /* translators: %d: product ID */
+                __('Product #%d (deleted)', 'navarro-booking-for-woocommerce'),
+                $id
+            );
+        }
+        asort($out);
+        return $out;
+    }
+
+    protected function extra_tablenav($which) {
+        if ($which !== 'top') {
+            return;
+        }
+        $f = self::filters();
+        ?>
+        <div class="alignleft actions">
+            <label class="screen-reader-text" for="calentra-filter-product"><?php esc_html_e('Filter by product', 'navarro-booking-for-woocommerce'); ?></label>
+            <select name="product" id="calentra-filter-product">
+                <option value="0"><?php esc_html_e('All products', 'navarro-booking-for-woocommerce'); ?></option>
+                <?php foreach (self::booked_products() as $id => $title) : ?>
+                    <option value="<?php echo (int) $id; ?>" <?php selected($f['product'], $id); ?>><?php echo esc_html($title); ?></option>
+                <?php endforeach; ?>
+            </select>
+
+            <label class="screen-reader-text" for="calentra-filter-status"><?php esc_html_e('Filter by status', 'navarro-booking-for-woocommerce'); ?></label>
+            <select name="booking_status" id="calentra-filter-status">
+                <?php foreach (self::status_labels() as $value => $label) : ?>
+                    <option value="<?php echo esc_attr($value); ?>" <?php selected($f['status'], $value); ?>><?php echo esc_html($label); ?></option>
+                <?php endforeach; ?>
+            </select>
+
+            <label for="calentra-filter-from"><?php esc_html_e('From', 'navarro-booking-for-woocommerce'); ?></label>
+            <input type="date" name="from" id="calentra-filter-from" value="<?php echo esc_attr($f['from']); ?>">
+            <label for="calentra-filter-to"><?php esc_html_e('to', 'navarro-booking-for-woocommerce'); ?></label>
+            <input type="date" name="to" id="calentra-filter-to" value="<?php echo esc_attr($f['to']); ?>">
+
+            <?php submit_button(__('Filter', 'navarro-booking-for-woocommerce'), '', 'filter_action', false); ?>
+        </div>
+        <?php
+    }
+
+    private static function status_labels() {
+        return [
+            'active'    => __('Active (confirmed and live holds)', 'navarro-booking-for-woocommerce'),
+            'confirmed' => __('Confirmed', 'navarro-booking-for-woocommerce'),
+            'hold'      => __('In a cart (hold)', 'navarro-booking-for-woocommerce'),
+            'cancelled' => __('Cancelled', 'navarro-booking-for-woocommerce'),
+            'expired'   => __('Expired hold', 'navarro-booking-for-woocommerce'),
+            'all'       => __('All statuses', 'navarro-booking-for-woocommerce'),
+        ];
+    }
+
+    protected function column_default($item, $column_name) {
+        $tz = wp_timezone();
+        switch ($column_name) {
+            case 'id':
+                return '#' . (int) $item->id;
+
+            case 'product':
+                $link = get_edit_post_link($item->product_id);
+                $title = get_the_title($item->product_id) ?: '#' . (int) $item->product_id;
+                return $link ? '<a href="' . esc_url($link) . '">' . esc_html($title) . '</a>' : esc_html($title);
+
+            case 'resource':
+                if (!(int) $item->resource_id) {
+                    return '—';
+                }
+                /**
+                 * Filters how a resource is named in the bookings list. Core has no
+                 * resource names of its own, so it shows the ID.
+                 *
+                 * @param string $label       Default "#<id>".
+                 * @param int    $resource_id Resource ID.
+                 * @param object $booking     The booking row.
+                 */
+                return esc_html(apply_filters('calentra_resource_label', '#' . (int) $item->resource_id, (int) $item->resource_id, $item));
+
+            case 'start':
+            case 'end':
+                $ts = strtotime(($column_name === 'start' ? $item->start_utc : $item->end_utc) . ' UTC');
+                $format = $item->booking_type === 'multi-day'
+                    ? get_option('date_format')
+                    : get_option('date_format') . ' ' . get_option('time_format') . ' T';
+                return esc_html(wp_date($format, $ts, $tz));
+
+            case 'seats':
+                return (int) $item->seats;
+
+            case 'status':
+                $labels = self::status_labels();
+                if ($item->status === 'hold') {
+                    $expires = strtotime($item->hold_expires . ' UTC');
+                    if ($expires < time()) {
+                        return esc_html__('Hold (lapsed)', 'navarro-booking-for-woocommerce');
+                    }
+                    return esc_html(sprintf(
+                        /* translators: %s: time the hold expires */
+                        __('In a cart until %s', 'navarro-booking-for-woocommerce'),
+                        wp_date(get_option('time_format') . ' T', $expires, $tz)
+                    ));
+                }
+                return esc_html($labels[$item->status] ?? $item->status);
+
+            case 'order':
+                if (!$item->order_id) {
+                    return '—';
+                }
+                $order = wc_get_order($item->order_id);
+                if (!$order) {
+                    /* translators: %d: order number */
+                    return esc_html(sprintf(__('#%d (deleted)', 'navarro-booking-for-woocommerce'), $item->order_id));
+                }
+                return '<a href="' . esc_url($order->get_edit_order_url()) . '">#' . esc_html($order->get_order_number()) . '</a> '
+                    . '<span class="description">' . esc_html(wc_get_order_status_name($order->get_status())) . '</span>';
+        }
+        return '';
+    }
+
+    /**
+     * Renders the whole screen.
+     */
+    public static function render_page() {
+        if (!current_user_can('manage_woocommerce')) {
+            return;
+        }
+        $table = new self();
+        $table->prepare_items();
+        ?>
+        <div class="wrap">
+            <h1 class="wp-heading-inline"><?php esc_html_e('Bookings', 'navarro-booking-for-woocommerce'); ?></h1>
+            <?php Calentra_Upgrade::render(Calentra_Upgrade::LOCATION_BOOKINGS_ACTIONS); ?>
+            <p class="description"><?php
+                /* translators: %s: timezone name */
+                printf(esc_html__('Times are shown in the site timezone (%s).', 'navarro-booking-for-woocommerce'), esc_html(wp_timezone_string()));
+            ?></p>
+            <form method="get">
+                <input type="hidden" name="page" value="calentra-bookings">
+                <?php $table->display(); ?>
+            </form>
+        </div>
+        <?php
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-bookings.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-bookings.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-bookings.php	(revision 3750436)
@@ -0,0 +1,779 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/*
+ * Every query in this file runs against Navarro Booking's own tables, which WordPress has no
+ * API for, so the direct-query and caching sniffs cannot be satisfied here. They are
+ * switched off for the file rather than line by line, on one invariant:
+ *
+ *   table names come only from Calentra_Install::bookings_table() and
+ *   inventory_table(), each of which is $wpdb->prefix plus a literal, and every value
+ *   is a %d or %s placeholder passed through $wpdb->prepare().
+ *
+ * Availability is deliberately uncached: a stale read here would sell a seat twice,
+ * which is the failure this class exists to prevent. The REST layer caches instead,
+ * where a short TTL is safe.
+ *
+ * If you add a query that interpolates anything other than those two table names,
+ * re-enable the sniffs around it rather than widening this block.
+ */
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+/**
+ * Booking rows and slot inventory.
+ *
+ * Integrity rests on one statement: a conditional UPDATE on the inventory counter,
+ *
+ *     UPDATE inventory SET seats_used = seats_used + n
+ *      WHERE <unit> AND seats_used + n <= capacity
+ *
+ * which InnoDB executes atomically under a row lock. If it affects no row, the unit
+ * filled up between the availability read and this write, and the claim is refused.
+ * No transaction or cron job is involved.
+ *
+ * A "unit" is what capacity is counted against: the slot start for a timeslot booking,
+ * and each night (local midnight, in UTC) for a multi-day booking.
+ *
+ * A booking on a resource (resource_id > 0) is counted twice: on the product's own row,
+ * as above, and on the resource's shared rows (product_id = 0), which every product
+ * using that resource draws from. The resource's capacity is the pool; a product's own
+ * capacity on it only caps what that product may take. Shared rows are keyed on
+ * five-minute granules rather than slot starts, because products sharing a resource can
+ * have different slot grids: a 18:15-19:30 class overlaps an 18:00-19:00 one without
+ * starting at the same instant. See resource_units_for() and claim().
+ *
+ * Every status change that gives seats back is itself a conditional UPDATE on the
+ * booking row (WHERE status = <expected>), so exactly one caller wins the right to
+ * decrement the counter, even if two requests release the same booking at once.
+ */
+class Calentra_Bookings {
+
+    const STATUS_HOLD = 'hold';
+    const STATUS_CONFIRMED = 'confirmed';
+    const STATUS_CANCELLED = 'cancelled';
+    const STATUS_EXPIRED = 'expired';
+
+    /** Upper bound on resources tried for one claim; see claim_candidates(). */
+    const MAX_CLAIM_CANDIDATES = 50;
+
+    /**
+     * Length of a resource-wide unit for a timeslot booking, in seconds.
+     *
+     * Deliberately not filterable. Every shared inventory row already written is keyed
+     * on this granule, and seats are given back by recomputing the granules from the
+     * booking; changing it on a live site would give back seats on rows that were never
+     * claimed and leave the real ones counted forever. Changing it needs a DB_VERSION
+     * bump and a rebuild of the inventory, not a filter.
+     */
+    const RESOURCE_GRANULE = 300;
+
+    /** Upper bound on resource-wide units for one booking (about 33 hours of granules). */
+    const MAX_RESOURCE_UNITS = 400;
+
+    public static function hold_seconds($product_id) {
+        /**
+         * Filters how long an add-to-cart hold reserves a slot, in seconds.
+         *
+         * @param int $seconds    Default from the settings page (60 minutes).
+         * @param int $product_id Product ID.
+         */
+        $minutes = (int) get_option('calentra_hold_minutes', 60) ?: 60;
+        return max(60, (int) apply_filters('calentra_hold_duration', $minutes * MINUTE_IN_SECONDS, (int) $product_id));
+    }
+
+    public static function get($booking_id) {
+        global $wpdb;
+        return $wpdb->get_row($wpdb->prepare(
+            'SELECT * FROM ' . Calentra_Install::bookings_table() . ' WHERE id = %d',
+            $booking_id
+        ));
+    }
+
+    public static function for_order($order_id) {
+        global $wpdb;
+        return $wpdb->get_results($wpdb->prepare(
+            'SELECT * FROM ' . Calentra_Install::bookings_table() . ' WHERE order_id = %d',
+            $order_id
+        ));
+    }
+
+    /**
+     * A live hold this cart session already has on the same slot, if any.
+     */
+    public static function find_session_hold($session_key, $product_id, $resource_id, $start_utc) {
+        global $wpdb;
+        if ($session_key === '') {
+            return null;
+        }
+        return $wpdb->get_row($wpdb->prepare(
+            "SELECT * FROM %i WHERE session_key = %s AND product_id = %d AND resource_id = %d
+                AND start_utc = %s AND status = 'hold' AND hold_expires >= %s ORDER BY id DESC LIMIT 1",
+            Calentra_Install::bookings_table(), $session_key, $product_id, $resource_id, $start_utc, gmdate(Calentra_Availability::UTC_FORMAT)
+        ));
+    }
+
+    /**
+     * Capacity units a booking occupies, as UTC "Y-m-d H:i:s" strings.
+     *
+     * @param object|array $booking Row with booking_type, start_utc, end_utc, site_timezone.
+     */
+    public static function units_for($booking) {
+        $booking = (object) $booking;
+        if (($booking->booking_type ?? 'timeslot') !== 'multi-day') {
+            return [$booking->start_utc];
+        }
+        try {
+            $tz = new DateTimeZone($booking->site_timezone ?: 'UTC');
+        } catch (Exception $e) {
+            $tz = wp_timezone();
+        }
+        $units = [];
+        $night = (new DateTimeImmutable($booking->start_utc . ' UTC'))->setTimezone($tz);
+        $end = strtotime($booking->end_utc . ' UTC');
+        while ($night->getTimestamp() < $end && count($units) < 400) {
+            $units[] = gmdate(Calentra_Availability::UTC_FORMAT, $night->getTimestamp());
+            $night = $night->modify('+1 day')->setTime(0, 0);
+        }
+        return $units;
+    }
+
+    /**
+     * Resource-wide units a booking occupies on its resource's shared rows, as UTC
+     * "Y-m-d H:i:s" strings in ascending order.
+     *
+     * Timeslot: every RESOURCE_GRANULE granule that [start, end) touches, rounded
+     * outwards, so any two bookings that overlap in time share at least one granule
+     * whatever their slot grids. A boundary off the five-minute grid can make two
+     * bookings that only touch share a granule; that refuses a booking, never oversells.
+     *
+     * Multi-day: the same nights as units_for(). A resource is never shared between a
+     * multi-day and a timeslot product (the extension attaching resources must refuse
+     * it, as Navarro Booking Pro does), so nights and granules never meet on one row.
+     *
+     * @param object|array $booking Row with booking_type, start_utc, end_utc, site_timezone.
+     * @return string[]|null Null if the booking spans more than MAX_RESOURCE_UNITS.
+     */
+    public static function resource_units_for($booking) {
+        $booking = (object) $booking;
+        if (($booking->booking_type ?? 'timeslot') === 'multi-day') {
+            return self::units_for($booking);
+        }
+        $granule = self::RESOURCE_GRANULE;
+        $start = (int) strtotime($booking->start_utc . ' UTC');
+        $end = (int) strtotime($booking->end_utc . ' UTC');
+        $first = intdiv($start, $granule) * $granule;
+        $last = max($first + $granule, intdiv($end + $granule - 1, $granule) * $granule);
+        if (($last - $first) / $granule > self::MAX_RESOURCE_UNITS) {
+            return null;
+        }
+        $units = [];
+        for ($t = $first; $t < $last; $t += $granule) {
+            $units[] = gmdate(Calentra_Availability::UTC_FORMAT, $t);
+        }
+        return $units;
+    }
+
+    /**
+     * Atomically takes $seats for a booking on every unit it occupies, or on none.
+     *
+     * On a resource, the resource's shared granules are taken first, all of them in
+     * ascending time order, and the product's own units after. Both orders matter:
+     *
+     *  - Resource first: when the resource is full, the claim fails on its first step
+     *    with nothing taken and nothing to roll back. The other order would make that,
+     *    the common refusal, the one that rolls back, and while a rollback is in flight
+     *    a concurrent claim can be refused a seat that exists.
+     *  - Ascending time: two overlapping claims meet first on their earliest shared
+     *    granule, and the loser fails there, before taking anything the winner needs
+     *    later. Claims for the same interval (every racer for one slot) are decided on
+     *    the first granule with no rollback at all.
+     *
+     * Three or more claims with different, partly overlapping intervals can still make
+     * one fail while another is rolling back (a refusal of a seat that turns out to be
+     * free a moment later). That needs differing intervals racing, refuses rather than
+     * oversells, and is accepted.
+     *
+     * @param object|array $booking Row with booking_type, start_utc, end_utc, site_timezone.
+     * @return bool False if any unit is full, or the booking is too long for the resource.
+     */
+    private static function claim($product_id, $resource_id, $booking, $seats) {
+        $steps = self::steps_for($product_id, $resource_id, $booking);
+        return $steps !== null && self::claim_steps($steps, $resource_id, $seats);
+    }
+
+    /**
+     * The rows a booking occupies, in claim order: [scope, unit] pairs, the resource's
+     * shared granules first (ascending), then the product's own units. Null if the
+     * booking is too long for its resource.
+     *
+     * @return array<int, array{0: int, 1: string}>|null
+     */
+    private static function steps_for($product_id, $resource_id, $booking) {
+        $steps = [];
+        if ((int) $resource_id > 0) {
+            $shared = self::resource_units_for($booking);
+            if ($shared === null) {
+                return null;
+            }
+            foreach ($shared as $unit) {
+                $steps[] = [0, $unit];
+            }
+        }
+        foreach (self::units_for($booking) as $unit) {
+            $steps[] = [(int) $product_id, $unit];
+        }
+        return $steps;
+    }
+
+    /**
+     * Takes $seats on each [scope, unit] row in order, or on none.
+     */
+    private static function claim_steps(array $steps, $resource_id, $seats) {
+        $resource_id = (int) $resource_id;
+        $taken = [];
+        foreach ($steps as $step) {
+            [$scope, $unit] = $step;
+            if (!self::claim_row($scope, $resource_id, $unit, $seats)) {
+                // All or nothing: give back every row this claim took, in either scope.
+                foreach ($taken as $done) {
+                    self::decrement($done[0], $resource_id, $done[1], $seats);
+                }
+                return false;
+            }
+            $taken[] = $step;
+        }
+        return true;
+    }
+
+    /**
+     * Takes $seats on one inventory row. $scope is the product, or 0 for the resource's
+     * shared row.
+     */
+    private static function claim_row($scope, $resource_id, $unit, $seats) {
+        global $wpdb;
+        $table = Calentra_Install::inventory_table();
+        // For the shared row this asks calentra_slot_capacity with product 0: the
+        // resource's own capacity, across every product.
+        $capacity = Calentra_Availability::capacity($scope, $resource_id, $unit);
+        // The inventory row is created lazily on first claim.
+        $wpdb->query($wpdb->prepare(
+            "INSERT IGNORE INTO $table (product_id, resource_id, start_utc, seats_total, seats_used) VALUES (%d, %d, %s, %d, 0)",
+            $scope, $resource_id, $unit, $capacity
+        ));
+        $ok = self::conditional_increment($scope, $resource_id, $unit, $seats, $capacity);
+        if (!$ok && self::reclaim_expired_holds($scope, $resource_id, $unit)) {
+            $ok = self::conditional_increment($scope, $resource_id, $unit, $seats, $capacity);
+        }
+        return $ok;
+    }
+
+    /**
+     * Gives back $seats a booking holds, on every row claim() took them from. The only
+     * way seats are returned for a booking: every release path goes through here, so
+     * none can return the product's seats and forget the resource's.
+     *
+     * The product's rows go back first, the reverse of claim(): a concurrent claim then
+     * finds the resource still full and is refused at once, instead of taking a shared
+     * seat and rolling it back.
+     *
+     * @param object|array $booking Row with product_id, resource_id and the times.
+     */
+    private static function give_back($booking, $seats) {
+        $booking = (object) $booking;
+        $product_id = (int) $booking->product_id;
+        $resource_id = (int) $booking->resource_id;
+        foreach (self::units_for($booking) as $unit) {
+            self::decrement($product_id, $resource_id, $unit, $seats);
+        }
+        if ($resource_id > 0) {
+            foreach ((array) self::resource_units_for($booking) as $unit) {
+                self::decrement(0, $resource_id, $unit, $seats);
+            }
+        }
+    }
+
+    private static function conditional_increment($product_id, $resource_id, $unit, $seats, $capacity) {
+        global $wpdb;
+        $table = Calentra_Install::inventory_table();
+        $wpdb->query($wpdb->prepare(
+            "UPDATE $table SET seats_used = seats_used + %d, seats_total = %d
+              WHERE product_id = %d AND resource_id = %d AND start_utc = %s
+                AND seats_used + %d <= %d",
+            $seats, $capacity, $product_id, $resource_id, $unit, $seats, $capacity
+        ));
+        return $wpdb->rows_affected === 1;
+    }
+
+    private static function decrement($product_id, $resource_id, $unit, $seats) {
+        global $wpdb;
+        $table = Calentra_Install::inventory_table();
+        $wpdb->query($wpdb->prepare(
+            "UPDATE $table SET seats_used = IF(seats_used >= %d, seats_used - %d, 0)
+              WHERE product_id = %d AND resource_id = %d AND start_utc = %s",
+            $seats, $seats, $product_id, $resource_id, $unit
+        ));
+    }
+
+    /**
+     * Returns the seats of holds that have expired on a unit to the counter. Each hold is
+     * retired with a conditional UPDATE, so only one request gives its seats back.
+     *
+     * For a resource's shared row ($scope 0) that means lapsed holds on the resource from
+     * every product, not just the one claiming: otherwise a lapsed hold on one product
+     * would keep the resource full for all the others until something else retired it.
+     * Retiring goes through give_back(), so each hold leaves both scopes.
+     *
+     * The candidate query may match more lapsed holds than strictly cover the unit (a
+     * granule test on a night, say). Retiring any lapsed hold is always correct.
+     *
+     * @return bool Whether any seats were returned.
+     */
+    private static function reclaim_expired_holds($scope, $resource_id, $unit) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        if ((int) $scope === 0 && (int) $resource_id > 0) {
+            // Holds overlapping the granule [unit, unit + RESOURCE_GRANULE), from any product.
+            $candidates = $wpdb->get_results($wpdb->prepare(
+                "SELECT * FROM $table
+                  WHERE resource_id = %d AND status = 'hold' AND hold_expires < %s
+                    AND start_utc < %s AND end_utc > %s",
+                $resource_id, $now, gmdate(Calentra_Availability::UTC_FORMAT, strtotime($unit . ' UTC') + self::RESOURCE_GRANULE), $unit
+            ));
+        } else {
+            $candidates = $wpdb->get_results($wpdb->prepare(
+                "SELECT * FROM $table
+                  WHERE product_id = %d AND resource_id = %d AND status = 'hold' AND hold_expires < %s
+                    AND start_utc <= %s AND end_utc > %s",
+                $scope, $resource_id, $now, $unit, $unit
+            ));
+        }
+        $freed = false;
+        foreach ((array) $candidates as $hold) {
+            if (self::transition($hold, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired')) {
+                $freed = true;
+            }
+        }
+        return $freed;
+    }
+
+    /**
+     * Conditionally moves a booking out of a seat-holding status and returns its seats.
+     */
+    private static function transition($booking, array $from, $to, $reason) {
+        global $wpdb;
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $from = array_pad(array_values($from), 2, $from[0]); // IN (%s, %s); a repeated status is harmless.
+        if ($to === self::STATUS_EXPIRED) {
+            // Expiring must not race a just-extended or just-confirmed hold.
+            $wpdb->query($wpdb->prepare(
+                'UPDATE %i SET status = %s, updated_at = %s WHERE id = %d AND status IN (%s, %s) AND hold_expires < %s',
+                Calentra_Install::bookings_table(), $to, $now, $booking->id, $from[0], $from[1], $now
+            ));
+        } else {
+            $wpdb->query($wpdb->prepare(
+                'UPDATE %i SET status = %s, updated_at = %s WHERE id = %d AND status IN (%s, %s)',
+                Calentra_Install::bookings_table(), $to, $now, $booking->id, $from[0], $from[1]
+            ));
+        }
+        if ($wpdb->rows_affected !== 1) {
+            return false;
+        }
+        self::give_back($booking, (int) $booking->seats);
+        Calentra_Availability::bump_cache_version($booking->product_id);
+
+        /**
+         * Fires when a booking gives its seats back.
+         *
+         * @param int    $booking_id Booking ID.
+         * @param string $reason     expired | cancelled | released.
+         * @param object $booking    The row before the change.
+         */
+        do_action('calentra_booking_released', (int) $booking->id, $reason, $booking);
+        return true;
+    }
+
+    /**
+     * Resources to try, in order, for a new claim. By default only the one requested.
+     *
+     * @return int[]
+     */
+    private static function claim_candidates($product_id, array $booking) {
+        /**
+         * Filters the resources a new booking may be claimed on, in the order to try
+         * them. Core claims each atomically in turn and the first with room wins; the
+         * booking records that resource. Use it to pool resources (any free member of
+         * staff, any free room) without reading availability first.
+         *
+         * Applies to every new claim: cart holds, re-claims of lapsed holds at checkout,
+         * restored orders and bookings created in the admin. Resizing or moving an
+         * existing booking stays on its resource.
+         *
+         * @param int[] $resources  Default: the requested resource only.
+         * @param int   $product_id Product ID.
+         * @param array $booking    The validated booking, with the requested resource_id.
+         *                          When a lapsed cart hold is re-claimed it also carries
+         *                          previous_resource_id, the resource it held before.
+         */
+        $candidates = (array) apply_filters('calentra_claim_resources', [(int) $booking['resource_id']], (int) $product_id, $booking);
+        // Only whole numbers 0 or above. Junk is dropped, not cast: (int) 'abc' is 0,
+        // and a negative ID stored in the unsigned column becomes 0 under a non-strict
+        // sql_mode; either would quietly book the product itself.
+        $candidates = array_values(array_unique(array_map('intval', array_filter($candidates, function ($id) {
+            return (is_int($id) && $id >= 0) || (is_string($id) && ctype_digit($id));
+        }))));
+        // Each candidate costs one conditional UPDATE per unit under contention.
+        return array_slice($candidates, 0, self::MAX_CLAIM_CANDIDATES);
+    }
+
+    /**
+     * Places a hold for a validated booking (see Calentra_Availability::validate()).
+     *
+     * @return int|WP_Error Booking ID.
+     */
+    public static function create_hold($product_id, array $booking, $session_key) {
+        return self::create($product_id, $booking, [
+            'status'       => self::STATUS_HOLD,
+            'hold_expires' => gmdate(Calentra_Availability::UTC_FORMAT, time() + self::hold_seconds($product_id)),
+            'session_key'  => substr((string) $session_key, 0, 64),
+        ]);
+    }
+
+    /**
+     * Creates a confirmed booking directly, for orders made without a cart (admin).
+     *
+     * @return int|WP_Error
+     */
+    public static function create_confirmed($product_id, array $booking, $order_id, $item_id) {
+        return self::create($product_id, $booking, [
+            'status'        => self::STATUS_CONFIRMED,
+            'order_id'      => (int) $order_id,
+            'order_item_id' => (int) $item_id,
+        ]);
+    }
+
+    private static function create($product_id, array $booking, array $fields) {
+        global $wpdb;
+        $row = (object) [
+            'product_id'    => (int) $product_id,
+            'resource_id'   => (int) $booking['resource_id'],
+            'booking_type'  => $booking['type'],
+            'start_utc'     => $booking['start_utc'],
+            'end_utc'       => $booking['end_utc'],
+            'site_timezone' => $booking['timezone'],
+            'seats'         => (int) $booking['seats'],
+        ];
+        $candidates = self::claim_candidates($row->product_id, $booking);
+        if (!$candidates) {
+            // Nothing was offered to claim, which is not a lost race: say so, and don't
+            // fire calentra_claim_failed.
+            return new WP_Error('calentra_no_resource', __('Choose a resource for this booking.', 'navarro-booking-for-woocommerce'));
+        }
+        if (max($candidates) > 0 && self::resource_units_for($row) === null) {
+            return self::too_long_error();
+        }
+
+        $winner = null;
+        foreach ($candidates as $resource_id) {
+            // Each attempt is the same atomic conditional UPDATE as a single claim. A
+            // full resource is skipped because its UPDATE affects no row, never because
+            // availability was read first.
+            if (self::claim($row->product_id, $resource_id, $row, $row->seats)) {
+                $winner = $resource_id;
+                break;
+            }
+        }
+        if ($winner === null) {
+            /**
+             * Fires when a claim loses the race for a slot: every candidate resource
+             * was full.
+             *
+             * @param int   $product_id Product ID.
+             * @param array $booking    The booking that could not be claimed.
+             */
+            do_action('calentra_claim_failed', (int) $product_id, $booking);
+            return new WP_Error('calentra_slot_taken', __('Sorry, someone else has just booked that slot. Please choose another.', 'navarro-booking-for-woocommerce'));
+        }
+        // From here on the row, the insert and any rollback must name the resource that
+        // was actually claimed, not the one that was asked for.
+        $row->resource_id = $winner;
+        $booking['resource_id'] = $winner;
+
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $data = array_merge((array) $row, $fields, ['created_at' => $now, 'updated_at' => $now]);
+        if (!$wpdb->insert(Calentra_Install::bookings_table(), $data)) {
+            self::give_back($row, $row->seats);
+            return new WP_Error('calentra_db', __('The booking could not be saved. Please try again.', 'navarro-booking-for-woocommerce'));
+        }
+        $id = (int) $wpdb->insert_id;
+        Calentra_Availability::bump_cache_version($product_id);
+
+        if ($fields['status'] === self::STATUS_HOLD) {
+            /**
+             * Fires after a cart hold is placed.
+             *
+             * @param int   $booking_id Booking ID.
+             * @param array $booking    The validated booking.
+             */
+            do_action('calentra_hold_created', $id, $booking);
+        } else {
+            /** This action is documented in includes/class-calentra-bookings.php */
+            do_action('calentra_booking_confirmed', $id, (int) $fields['order_id']);
+        }
+        return $id;
+    }
+
+    /**
+     * Changes the seats a live hold covers (the customer changed the cart quantity).
+     *
+     * @return bool|WP_Error
+     */
+    public static function resize_hold($booking_id, $seats) {
+        global $wpdb;
+        $booking = self::get($booking_id);
+        $seats = max(1, (int) $seats);
+        if (!$booking || $booking->status !== self::STATUS_HOLD) {
+            return new WP_Error('calentra_no_hold', __('Your reservation has expired.', 'navarro-booking-for-woocommerce'));
+        }
+        $delta = $seats - (int) $booking->seats;
+        if ($delta === 0) {
+            return true;
+        }
+        if ($delta > 0 && !self::claim((int) $booking->product_id, (int) $booking->resource_id, $booking, $delta)) {
+            return new WP_Error('calentra_slot_taken', __('Sorry, there are not enough places left in that slot.', 'navarro-booking-for-woocommerce'));
+        }
+        $wpdb->query($wpdb->prepare(
+            'UPDATE ' . Calentra_Install::bookings_table() . " SET seats = %d, updated_at = %s WHERE id = %d AND status = 'hold'",
+            $seats, gmdate(Calentra_Availability::UTC_FORMAT), $booking_id
+        ));
+        if ($wpdb->rows_affected !== 1) {
+            // The hold was retired underneath us; hand back what we just took.
+            if ($delta > 0) {
+                self::give_back($booking, $delta);
+            }
+            return new WP_Error('calentra_no_hold', __('Your reservation has expired.', 'navarro-booking-for-woocommerce'));
+        }
+        if ($delta < 0) {
+            self::give_back($booking, -$delta);
+        }
+        Calentra_Availability::bump_cache_version($booking->product_id);
+        return true;
+    }
+
+    /**
+     * Pushes a live hold's expiry forward (the customer is still active in the cart).
+     *
+     * @return bool False if the hold had already expired or gone.
+     */
+    public static function extend_hold($booking_id) {
+        global $wpdb;
+        $booking = self::get($booking_id);
+        if (!$booking) {
+            return false;
+        }
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $wpdb->query($wpdb->prepare(
+            "UPDATE %i SET hold_expires = %s, updated_at = %s
+              WHERE id = %d AND status = 'hold' AND hold_expires >= %s",
+            Calentra_Install::bookings_table(), gmdate(Calentra_Availability::UTC_FORMAT, time() + self::hold_seconds($booking->product_id)), $now, $booking_id, $now
+        ));
+        if ($wpdb->rows_affected === 1) {
+            return true;
+        }
+        // MySQL reports changed rows, not matched rows: extending twice in the same
+        // second writes identical values and reports 0. Check the hold directly.
+        return (bool) $wpdb->get_var($wpdb->prepare(
+            'SELECT 1 FROM ' . Calentra_Install::bookings_table() . " WHERE id = %d AND status = 'hold' AND hold_expires >= %s",
+            $booking_id, $now
+        ));
+    }
+
+    /**
+     * Converts a hold into a confirmed booking for an order. Idempotent: confirming a
+     * booking already confirmed for the same order succeeds.
+     *
+     * If the hold expired, its seats are returned and the slot is claimed afresh; the
+     * order only fails if someone else has taken the slot in the meantime.
+     *
+     * @return int|WP_Error Booking ID (may differ from the hold's if it was re-claimed).
+     */
+    public static function confirm($booking_id, $order_id, $item_id, $seats) {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $booking = self::get($booking_id);
+        if (!$booking) {
+            return new WP_Error('calentra_no_hold', __('Your reservation could not be found. Please choose your booking again.', 'navarro-booking-for-woocommerce'));
+        }
+        if ($booking->status === self::STATUS_CONFIRMED && (int) $booking->order_id === (int) $order_id) {
+            return (int) $booking->id;
+        }
+
+        if ($booking->status === self::STATUS_HOLD && (int) $booking->seats !== (int) $seats) {
+            $resized = self::resize_hold($booking_id, $seats);
+            if (is_wp_error($resized)) {
+                return $resized;
+            }
+            $booking = self::get($booking_id);
+        }
+
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $wpdb->query($wpdb->prepare(
+            "UPDATE $table SET status = 'confirmed', order_id = %d, order_item_id = %d, hold_expires = NULL, updated_at = %s
+              WHERE id = %d AND status = 'hold' AND hold_expires >= %s",
+            $order_id, $item_id, $now, $booking_id, $now
+        ));
+        if ($wpdb->rows_affected === 1) {
+            Calentra_Availability::bump_cache_version($booking->product_id);
+            /**
+             * Fires when a booking is confirmed against an order.
+             *
+             * @param int $booking_id Booking ID.
+             * @param int $order_id   Order ID.
+             */
+            do_action('calentra_booking_confirmed', (int) $booking_id, (int) $order_id);
+            return (int) $booking_id;
+        }
+
+        // The hold lapsed. Retire it (returning its seats if nobody has yet), then try
+        // to claim the same slot again.
+        if ($booking->status === self::STATUS_HOLD) {
+            self::transition($booking, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired');
+        }
+        $tz = $booking->site_timezone;
+        $request = [
+            'type'        => $booking->booking_type,
+            'start_utc'   => $booking->start_utc,
+            'end_utc'     => $booking->end_utc,
+            'timezone'    => $tz,
+            'seats'       => (int) $seats,
+            'resource_id' => (int) $booking->resource_id,
+        ];
+        $new_id = self::create_confirmed((int) $booking->product_id, $request, $order_id, $item_id);
+        if (is_wp_error($new_id)) {
+            return new WP_Error('calentra_slot_taken', __('Sorry, your reservation expired and the slot has since been booked by someone else. You have not been charged. Please choose another time.', 'navarro-booking-for-woocommerce'));
+        }
+        return $new_id;
+    }
+
+    /**
+     * Gives back the seats of a hold or confirmed booking.
+     */
+    public static function release($booking_id, $reason = 'released') {
+        $booking = self::get($booking_id);
+        if (!$booking) {
+            return false;
+        }
+        return self::transition($booking, [self::STATUS_HOLD, self::STATUS_CONFIRMED], self::STATUS_CANCELLED, $reason);
+    }
+
+    /**
+     * Releases an expired hold if it is still counted. Safe to call at any time.
+     */
+    public static function expire_if_lapsed($booking_id) {
+        $booking = self::get($booking_id);
+        if ($booking && $booking->status === self::STATUS_HOLD && strtotime($booking->hold_expires . ' UTC') < time()) {
+            return self::transition($booking, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired');
+        }
+        return false;
+    }
+
+    /**
+     * Moves a confirmed booking to a new time (admin edit). Claims the new units first,
+     * so a failed move leaves the original booking untouched.
+     *
+     * @return bool|WP_Error
+     */
+    public static function move($booking_id, array $booking) {
+        global $wpdb;
+        $old = self::get($booking_id);
+        if (!$old) {
+            return new WP_Error('calentra_missing', __('Booking not found.', 'navarro-booking-for-woocommerce'));
+        }
+        $new = clone $old;
+        $new->start_utc = $booking['start_utc'];
+        $new->end_utc = $booking['end_utc'];
+        $new->booking_type = $booking['type'];
+        $new->site_timezone = $booking['timezone'];
+
+        $new_steps = self::steps_for((int) $old->product_id, (int) $old->resource_id, $new);
+        if ($new_steps === null) {
+            return self::too_long_error();
+        }
+        // A booking that still holds seats only needs the rows it doesn't already hold,
+        // and gives back only the rows it leaves. Claiming the whole new interval would
+        // refuse any move that overlaps the old one (10:00 to 10:15 on a resource of one
+        // place) against the booking's own seats.
+        $active = in_array($old->status, [self::STATUS_HOLD, self::STATUS_CONFIRMED], true);
+        $old_steps = $active ? (array) self::steps_for((int) $old->product_id, (int) $old->resource_id, $old) : [];
+        $to_claim = self::steps_minus($new_steps, $old_steps);
+        $to_release = self::steps_minus($old_steps, $new_steps);
+        if (!self::claim_steps($to_claim, (int) $old->resource_id, (int) $old->seats)) {
+            return new WP_Error('calentra_slot_taken', __('That slot is already fully booked.', 'navarro-booking-for-woocommerce'));
+        }
+        $wpdb->update(Calentra_Install::bookings_table(), [
+            'start_utc'     => $new->start_utc,
+            'end_utc'       => $new->end_utc,
+            'booking_type'  => $new->booking_type,
+            'site_timezone' => $new->site_timezone,
+            'updated_at'    => gmdate(Calentra_Availability::UTC_FORMAT),
+        ], ['id' => $old->id]);
+        // Product rows first, as in give_back().
+        foreach (array_reverse($to_release) as $step) {
+            self::decrement($step[0], (int) $old->resource_id, $step[1], (int) $old->seats);
+        }
+        Calentra_Availability::bump_cache_version($old->product_id);
+        return true;
+    }
+
+    /**
+     * Steps in $a that are not in $b, keeping $a's order.
+     */
+    private static function steps_minus(array $a, array $b) {
+        $drop = [];
+        foreach ($b as $step) {
+            $drop[$step[0] . '|' . $step[1]] = true;
+        }
+        return array_values(array_filter($a, function ($step) use ($drop) {
+            return !isset($drop[$step[0] . '|' . $step[1]]);
+        }));
+    }
+
+    /**
+     * A booking on a resource longer than MAX_RESOURCE_UNITS granules. Refused outright:
+     * claiming part of its interval would leave the rest unprotected.
+     */
+    private static function too_long_error() {
+        return new WP_Error('calentra_too_long', __('This booking is too long to be made on a resource. Please choose a shorter booking.', 'navarro-booking-for-woocommerce'));
+    }
+
+    /**
+     * Optional tidy-up: retires lapsed holds and deletes very old non-active rows.
+     * Correctness never depends on this running; availability ignores expired holds and
+     * claims reclaim them on demand.
+     */
+    public static function sweep() {
+        global $wpdb;
+        $table = Calentra_Install::bookings_table();
+        $now = gmdate(Calentra_Availability::UTC_FORMAT);
+        $lapsed = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM $table WHERE status = 'hold' AND hold_expires < %s LIMIT 500",
+            $now
+        ));
+        foreach ((array) $lapsed as $hold) {
+            self::transition($hold, [self::STATUS_HOLD], self::STATUS_EXPIRED, 'expired');
+        }
+        $wpdb->query($wpdb->prepare(
+            "DELETE FROM $table WHERE status = 'expired' AND order_id IS NULL AND updated_at < %s",
+            gmdate(Calentra_Availability::UTC_FORMAT, time() - 30 * DAY_IN_SECONDS)
+        ));
+        $wpdb->query($wpdb->prepare(
+            'DELETE FROM ' . Calentra_Install::inventory_table() . ' WHERE seats_used = 0 AND start_utc < %s',
+            gmdate(Calentra_Availability::UTC_FORMAT, time() - DAY_IN_SECONDS)
+        ));
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-cart.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-cart.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-cart.php	(revision 3750436)
@@ -0,0 +1,430 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Cart, checkout and order lifecycle.
+ *
+ *  add to cart  → validate against Calentra_Availability, then claim a hold
+ *  cart / checkout views → keep the hold alive, re-claim it if it lapsed
+ *  order placed → convert hold to confirmed atomically, or fail the order
+ *  cancelled / failed / refunded → release
+ *
+ * These hooks run in every request type (front end, admin, AJAX, Store API), because
+ * the block cart and checkout go through the REST API and order status changes
+ * usually happen in the admin.
+ */
+class Calentra_Cart {
+
+    const ITEM_KEY = 'calentra';
+
+    /** Booking validated in add_to_cart_validation, used by the hooks that follow it. */
+    private $pending = null;
+
+    public function init_hooks() {
+        add_filter('woocommerce_add_to_cart_validation', [$this, 'validate_add_to_cart'], 10, 5);
+        add_filter('woocommerce_add_cart_item_data', [$this, 'add_cart_item_data'], 10, 3);
+        add_action('woocommerce_add_to_cart', [$this, 'place_hold'], 10, 6);
+        add_action('woocommerce_remove_cart_item', [$this, 'release_removed_item'], 10, 2);
+        add_action('woocommerce_after_cart_item_quantity_update', [$this, 'resize_hold'], 10, 4);
+        add_action('woocommerce_check_cart_items', [$this, 'check_cart_holds']);
+        add_filter('woocommerce_get_item_data', [$this, 'display_cart_item'], 10, 2);
+        add_action('woocommerce_before_calculate_totals', [$this, 'apply_multiday_price'], 20);
+
+        add_action('woocommerce_checkout_create_order_line_item', [$this, 'add_order_item_meta'], 10, 4);
+        add_action('woocommerce_checkout_order_processed', [$this, 'confirm_classic_checkout'], 10, 3);
+        add_action('woocommerce_store_api_checkout_order_processed', [$this, 'confirm_store_api_checkout']);
+
+        foreach (['cancelled', 'failed', 'refunded'] as $status) {
+            add_action("woocommerce_order_status_{$status}", [$this, 'release_order']);
+        }
+        add_action('woocommerce_before_trash_order', [$this, 'release_order']);
+        add_action('woocommerce_before_delete_order', [$this, 'release_order']);
+        add_action('wp_trash_post', [$this, 'release_order_post']);
+        add_action('woocommerce_order_status_changed', [$this, 'reclaim_on_restore'], 10, 3);
+    }
+
+    private static function session_key() {
+        return (function_exists('WC') && WC()->session) ? (string) WC()->session->get_customer_id() : '';
+    }
+
+    /**
+     * Booking fields from an add-to-cart request. The classic product form posts them;
+     * WooCommerce does not put a nonce on add-to-cart, and these values are validated
+     * against availability before anything is stored.
+     */
+    private function request_fields() {
+        // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended
+        $read = function ($key) {
+            return isset($_REQUEST[$key]) ? sanitize_text_field(wp_unslash($_REQUEST[$key])) : '';
+        };
+        // phpcs:enable
+        return [
+            'date'     => $read('calentra_booking_date'),
+            'time'     => $read('calentra_booking_time'),
+            'end_date' => $read('calentra_booking_end_date'),
+        ];
+    }
+
+    private static function resource_id($product_id, array $fields) {
+        /**
+         * Filters which resource a booking is made against. 0 means the product itself.
+         *
+         * @param int   $resource_id Default 0.
+         * @param int   $product_id  Product ID.
+         * @param array $fields      The requested date / time / end_date.
+         */
+        return (int) apply_filters('calentra_booking_resource_id', 0, (int) $product_id, $fields);
+    }
+
+    /** Quantity of the same booking already in the cart, which a merge would add to. */
+    private function quantity_in_cart($product_id, array $booking) {
+        $qty = 0;
+        if (!WC()->cart) {
+            return 0;
+        }
+        foreach (WC()->cart->get_cart() as $item) {
+            if ((int) $item['product_id'] === (int) $product_id && !empty($item[self::ITEM_KEY])
+                && $item[self::ITEM_KEY]['start_utc'] === $booking['start_utc']) {
+                $qty += (int) $item['quantity'];
+            }
+        }
+        return $qty;
+    }
+
+    public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id = 0, $variations = []) {
+        $this->pending = null;
+        $config = Calentra_Rules::product_config($product_id);
+        if (!$passed || !$config['enabled']) {
+            return $passed;
+        }
+
+        $fields = $this->request_fields();
+        $args = ['session_key' => self::session_key(), 'resource_id' => self::resource_id($product_id, $fields)];
+        $booking = Calentra_Availability::validate($product_id, $fields + ['seats' => $quantity], $args);
+        if (!is_wp_error($booking)) {
+            $in_cart = $this->quantity_in_cart($product_id, $booking);
+            if ($in_cart > 0) {
+                $booking = Calentra_Availability::validate($product_id, $fields + ['seats' => $quantity + $in_cart], $args);
+            }
+        }
+        if (is_wp_error($booking)) {
+            wc_add_notice($booking->get_error_message(), 'error');
+            return false;
+        }
+        $this->pending = $booking;
+        return true;
+    }
+
+    public function add_cart_item_data($cart_item_data, $product_id, $variation_id) {
+        if ($this->pending) {
+            $b = $this->pending;
+            // Only the booking identity goes in, so adding the same slot twice merges
+            // into one line rather than creating two.
+            $cart_item_data[self::ITEM_KEY] = [
+                'type'        => $b['type'],
+                'date'        => $b['date'],
+                'time'        => $b['time'],
+                'end_date'    => $b['end_date'],
+                'start_utc'   => $b['start_utc'],
+                'end_utc'     => $b['end_utc'],
+                'timezone'    => $b['timezone'],
+                'resource_id' => $b['resource_id'],
+            ];
+        }
+        return $cart_item_data;
+    }
+
+    public function place_hold($cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data) {
+        if (empty($cart_item_data[self::ITEM_KEY]) || !$this->pending) {
+            return;
+        }
+        $cart = WC()->cart;
+        $item = $cart->cart_contents[$cart_item_key];
+        $total = (int) $item['quantity'];
+        $hold_id = (int) ($item['calentra_hold_id'] ?? 0);
+
+        if ($hold_id) {
+            $result = Calentra_Bookings::resize_hold($hold_id, $total);
+            if (is_wp_error($result)) {
+                $cart->cart_contents[$cart_item_key]['quantity'] = $total - (int) $quantity;
+                throw new Exception(esc_html($result->get_error_message()));
+            }
+        } else {
+            $booking = $this->pending;
+            $booking['seats'] = $total;
+            $result = Calentra_Bookings::create_hold($product_id, $booking, self::session_key());
+            if (is_wp_error($result)) {
+                unset($cart->cart_contents[$cart_item_key]);
+                throw new Exception(esc_html($result->get_error_message()));
+            }
+            $cart->cart_contents[$cart_item_key]['calentra_hold_id'] = $result;
+            self::remember_claimed_resource($cart, $cart_item_key, $result);
+        }
+        $this->pending = null;
+    }
+
+    public function release_removed_item($cart_item_key, $cart) {
+        $item = $cart->cart_contents[$cart_item_key] ?? null;
+        if (!empty($item['calentra_hold_id'])) {
+            $hold = Calentra_Bookings::get($item['calentra_hold_id']);
+            // Only a hold; a confirmed booking belongs to an order now.
+            if ($hold && $hold->status === Calentra_Bookings::STATUS_HOLD) {
+                Calentra_Bookings::release($hold->id, 'removed_from_cart');
+            }
+        }
+    }
+
+    public function resize_hold($cart_item_key, $quantity, $old_quantity, $cart) {
+        $item = $cart->cart_contents[$cart_item_key] ?? null;
+        if (empty($item['calentra_hold_id']) || (int) $quantity === (int) $old_quantity) {
+            return;
+        }
+        $result = Calentra_Bookings::resize_hold($item['calentra_hold_id'], $quantity);
+        if (is_wp_error($result) && $result->get_error_code() === 'calentra_slot_taken') {
+            $cart->cart_contents[$cart_item_key]['quantity'] = $old_quantity;
+            wc_add_notice($result->get_error_message(), 'error');
+        }
+        // An expired hold is dealt with by check_cart_holds().
+    }
+
+    /**
+     * Runs whenever the cart or checkout is shown or validated (classic and Store API).
+     * Keeps live holds alive, and re-claims lapsed ones if the slot is still free. A
+     * notice of type error here blocks checkout.
+     */
+    public function check_cart_holds() {
+        $cart = WC()->cart;
+        if (!$cart) {
+            return;
+        }
+        foreach ($cart->get_cart() as $key => $item) {
+            if (empty($item[self::ITEM_KEY])) {
+                continue;
+            }
+            $hold_id = (int) ($item['calentra_hold_id'] ?? 0);
+            $hold = $hold_id ? Calentra_Bookings::get($hold_id) : null;
+
+            if ($hold && $hold->status === Calentra_Bookings::STATUS_HOLD && Calentra_Bookings::extend_hold($hold_id)) {
+                if ((int) $hold->seats !== (int) $item['quantity']) {
+                    $resized = Calentra_Bookings::resize_hold($hold_id, $item['quantity']);
+                    if (is_wp_error($resized)) {
+                        wc_add_notice($resized->get_error_message(), 'error');
+                    }
+                }
+                continue;
+            }
+
+            if ($hold_id) {
+                Calentra_Bookings::expire_if_lapsed($hold_id);
+            }
+            $b = $item[self::ITEM_KEY];
+            $product_id = (int) $item['product_id'];
+
+            // An earlier request may already have re-claimed this slot for the session
+            // without the new ID reaching the stored cart. Adopt it rather than claim twice.
+            $own = Calentra_Bookings::find_session_hold(self::session_key(), $product_id, self::claimed_resource($b), $b['start_utc']);
+            if ($own && Calentra_Bookings::extend_hold($own->id)) {
+                $cart->cart_contents[$key]['calentra_hold_id'] = (int) $own->id;
+                $cart->set_session();
+                continue;
+            }
+            $booking = Calentra_Availability::validate($product_id, [
+                'date'     => $b['date'],
+                'time'     => $b['time'],
+                'end_date' => $b['end_date'],
+                'seats'    => $item['quantity'],
+            ], ['session_key' => self::session_key(), 'resource_id' => (int) $b['resource_id']]);
+            if (!is_wp_error($booking)) {
+                // Validated against the resource the customer asked for (0 for a pool),
+                // but the one they held before is worth trying first.
+                $booking['previous_resource_id'] = self::claimed_resource($b);
+            }
+            $new_id = is_wp_error($booking) ? $booking : Calentra_Bookings::create_hold($product_id, $booking, self::session_key());
+
+            if (is_wp_error($new_id)) {
+                wc_add_notice(sprintf(
+                    /* translators: 1: product name, 2: error message */
+                    __('%1$s: your reservation expired and the slot is no longer available. Please remove it from your cart and choose another time. (%2$s)', 'navarro-booking-for-woocommerce'),
+                    $item['data']->get_name(),
+                    $new_id->get_error_message()
+                ), 'error');
+                continue;
+            }
+            $cart->cart_contents[$key]['calentra_hold_id'] = $new_id;
+            self::remember_claimed_resource($cart, $key, $new_id);
+            $cart->set_session();
+        }
+    }
+
+    /**
+     * Records which resource a hold was actually claimed on. With a pool of resources
+     * (see calentra_claim_resources) that can differ from the requested resource_id,
+     * which stays as asked so a re-claim validates against the whole pool again.
+     * Looking up the session's own hold must use the claimed one, or it would miss the
+     * hold and claim a second seat.
+     */
+    private static function remember_claimed_resource($cart, $cart_item_key, $hold_id) {
+        $hold = Calentra_Bookings::get($hold_id);
+        if ($hold) {
+            $cart->cart_contents[$cart_item_key][self::ITEM_KEY]['claimed_resource_id'] = (int) $hold->resource_id;
+        }
+    }
+
+    private static function claimed_resource(array $b) {
+        return isset($b['claimed_resource_id']) ? (int) $b['claimed_resource_id'] : (int) $b['resource_id'];
+    }
+
+    public function display_cart_item($item_data, $cart_item) {
+        if (empty($cart_item[self::ITEM_KEY])) {
+            return $item_data;
+        }
+        foreach (Calentra_Display::lines($cart_item[self::ITEM_KEY]) as $label => $value) {
+            $item_data[] = ['key' => $label, 'value' => $value];
+        }
+        return $item_data;
+    }
+
+    /**
+     * Multi-day price is the product price per night.
+     */
+    public function apply_multiday_price($cart) {
+        foreach ($cart->get_cart() as $cart_item) {
+            $b = $cart_item[self::ITEM_KEY] ?? null;
+            if (!$b || $b['type'] !== 'multi-day') {
+                continue;
+            }
+            $nights = count(Calentra_Bookings::units_for((object) [
+                'booking_type'  => 'multi-day',
+                'start_utc'     => $b['start_utc'],
+                'end_utc'       => $b['end_utc'],
+                'site_timezone' => $b['timezone'],
+            ]));
+            // Read the price fresh: this hook can run several times per request, and
+            // multiplying the already-adjusted price would compound.
+            $source = wc_get_product($cart_item['variation_id'] ?: $cart_item['product_id']);
+            if (!$source) {
+                continue;
+            }
+            $price = (float) $source->get_price() * max(1, $nights);
+
+            /**
+             * Filters the unit price of a booking in the cart.
+             *
+             * @param float      $price     Price per unit of quantity.
+             * @param array      $cart_item Cart item.
+             * @param WC_Product $product   Product.
+             */
+            $cart_item['data']->set_price(apply_filters('calentra_booking_price', $price, $cart_item, $source));
+        }
+    }
+
+    public function add_order_item_meta($item, $cart_item_key, $values, $order) {
+        if (empty($values[self::ITEM_KEY])) {
+            return;
+        }
+        $b = $values[self::ITEM_KEY];
+        $item->update_meta_data('_calentra_booking_id', (int) ($values['calentra_hold_id'] ?? 0));
+        Calentra_Display::write_item_meta($item, $b);
+    }
+
+    public function confirm_classic_checkout($order_id, $posted_data, $order) {
+        $error = $this->confirm_order($order);
+        if ($error) {
+            throw new Exception(esc_html($error));
+        }
+    }
+
+    public function confirm_store_api_checkout($order) {
+        $error = $this->confirm_order($order);
+        if ($error) {
+            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('calentra_slot_taken', esc_html($error), 409);
+        }
+    }
+
+    /**
+     * Converts every booking hold on the order to confirmed. All or nothing: if one
+     * slot has gone, the others are released again and the order is failed before any
+     * payment is taken.
+     *
+     * @return string|null Error message, or null on success.
+     */
+    private function confirm_order(WC_Order $order) {
+        $confirmed = [];
+        foreach ($order->get_items() as $item_id => $item) {
+            $hold_id = (int) $item->get_meta('_calentra_booking_id');
+            if (!$hold_id) {
+                continue;
+            }
+            $result = Calentra_Bookings::confirm($hold_id, $order->get_id(), $item_id, $item->get_quantity());
+            if (is_wp_error($result)) {
+                foreach ($confirmed as $id) {
+                    Calentra_Bookings::release($id, 'checkout_failed');
+                }
+                $order->update_status('failed', sprintf(
+                    /* translators: %s: product name */
+                    __('Navarro Booking: the booking for %s could not be confirmed because the slot was taken. No payment was attempted.', 'navarro-booking-for-woocommerce'),
+                    $item->get_name()
+                ));
+                return $result->get_error_message();
+            }
+            if ($result !== $hold_id) {
+                $item->update_meta_data('_calentra_booking_id', $result);
+                $item->save();
+            }
+            $confirmed[] = $result;
+        }
+        return null;
+    }
+
+    public function release_order($order_id) {
+        foreach (Calentra_Bookings::for_order($order_id) as $booking) {
+            Calentra_Bookings::release($booking->id, 'order_' . current_action());
+        }
+    }
+
+    public function release_order_post($post_id) {
+        if (in_array(get_post_type($post_id), ['shop_order', 'shop_order_placeholder'], true)) {
+            $this->release_order($post_id);
+        }
+    }
+
+    /**
+     * An order moved from cancelled / failed / refunded back to a live status, for
+     * example a customer retrying a failed payment. Take the slot again if it is free.
+     */
+    public function reclaim_on_restore($order_id, $from, $to) {
+        $dead = ['cancelled', 'failed', 'refunded', 'trash'];
+        if (!in_array($from, $dead, true) || in_array($to, $dead, true)) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        foreach ($order->get_items() as $item_id => $item) {
+            $booking = Calentra_Bookings::get((int) $item->get_meta('_calentra_booking_id'));
+            if (!$booking || $booking->status === Calentra_Bookings::STATUS_CONFIRMED) {
+                continue;
+            }
+            $new_id = Calentra_Bookings::create_confirmed((int) $booking->product_id, [
+                'type'        => $booking->booking_type,
+                'start_utc'   => $booking->start_utc,
+                'end_utc'     => $booking->end_utc,
+                'timezone'    => $booking->site_timezone,
+                'seats'       => (int) $booking->seats,
+                'resource_id' => (int) $booking->resource_id,
+            ], $order_id, $item_id);
+            if (is_wp_error($new_id)) {
+                $order->add_order_note(sprintf(
+                    /* translators: %s: product name */
+                    __('Navarro Booking: the booking for %s could not be restored because the slot has since been booked by someone else.', 'navarro-booking-for-woocommerce'),
+                    $item->get_name()
+                ));
+                continue;
+            }
+            $item->update_meta_data('_calentra_booking_id', $new_id);
+            $item->save();
+        }
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-diagnostics.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-diagnostics.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-diagnostics.php	(revision 3750436)
@@ -0,0 +1,197 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Timezone diagnostics: what the site, the server, the database and the viewer each
+ * think the time is, how upcoming bookings are stored, and where DST changes fall.
+ *
+ * "The booking shows the wrong time" is the most common complaint about booking
+ * plugins, and is usually a timezone mismatch nobody can see. This makes it visible.
+ */
+class Calentra_Diagnostics {
+
+    /**
+     * The diagnostics assets, on the diagnostics tab only. Registered here rather than
+     * printed inline so the browser can cache them and so nothing in this plugin emits a
+     * <script> or <style> tag of its own.
+     *
+     * The viewer's own timezone is the one clock the server cannot know, so the comparison
+     * has to happen in the browser; the site's timezone is handed over as data.
+     */
+    public static function enqueue() {
+        wp_enqueue_style(
+            'calentra-diagnostics',
+            CALENTRA_PLUGIN_URL . 'assets/css/admin-diagnostics.css',
+            [],
+            calentra_asset_version('assets/css/admin-diagnostics.css')
+        );
+        wp_enqueue_script(
+            'calentra-diagnostics',
+            CALENTRA_PLUGIN_URL . 'assets/js/admin-diagnostics.js',
+            [],
+            calentra_asset_version('assets/js/admin-diagnostics.js'),
+            true
+        );
+        wp_localize_script('calentra-diagnostics', 'calentraDiagnostics', [
+            'siteTimezone'          => wp_timezone_string(),
+            'differentTimezoneNote' => __('Your browser is in a different timezone from the site. Customers in your timezone will see both times next to each slot.', 'navarro-booking-for-woocommerce'),
+        ]);
+    }
+
+    public function render() {
+        global $wpdb;
+        $tz = wp_timezone();
+        $tz_name = wp_timezone_string();
+        // Without a timezone_string WordPress falls back to a fixed gmt_offset.
+        $is_offset = !get_option('timezone_string');
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- asking the database for its own clock; there is no API for it and caching would defeat the point.
+        $db_now = $wpdb->get_var('SELECT UTC_TIMESTAMP()');
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- as above.
+        $db_tz = $wpdb->get_var('SELECT @@session.time_zone');
+        $drift = $db_now ? abs(strtotime($db_now . ' UTC') - time()) : null;
+
+        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+        if (!empty($_GET['rebuilt'])) {
+            echo '<div class="notice notice-success"><p>' . esc_html__('Inventory counters rebuilt from bookings.', 'navarro-booking-for-woocommerce') . '</p></div>';
+        }
+        ?>
+        <h2><?php esc_html_e('Clocks', 'navarro-booking-for-woocommerce'); ?></h2>
+        <table class="widefat striped calentra-diag" style="max-width:900px">
+            <tbody>
+            <tr><th><?php esc_html_e('Site timezone (Settings → General)', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><code><?php echo esc_html($tz_name); ?></code>
+                    — <?php echo esc_html(wp_date('Y-m-d H:i T (P)', time(), $tz)); ?>
+                    <?php if ($is_offset) : ?>
+                        <p class="calentra-warn">⚠ <?php esc_html_e('This is a fixed UTC offset, not a city. Fixed offsets do not follow daylight saving time, so slot times will be an hour off for part of the year. Choose a city such as "America/New_York" instead.', 'navarro-booking-for-woocommerce'); ?></p>
+                    <?php endif; ?>
+                </td></tr>
+            <tr><th><?php esc_html_e('Your browser timezone', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><code id="calentra-viewer-tz">…</code> — <span id="calentra-viewer-now">…</span>
+                    <p class="description" id="calentra-viewer-note"></p></td></tr>
+            <tr><th><?php esc_html_e('Server clock (UTC)', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><?php echo esc_html(gmdate('Y-m-d H:i:s')); ?> UTC</td></tr>
+            <tr><th><?php esc_html_e('Database clock (UTC)', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><?php echo esc_html((string) $db_now); ?> UTC · <?php esc_html_e('session time_zone', 'navarro-booking-for-woocommerce'); ?> <code><?php echo esc_html((string) $db_tz); ?></code>
+                    <?php if ($drift !== null && $drift > 60) : ?>
+                        <p class="calentra-warn">⚠ <?php
+                            /* translators: %d: seconds */
+                            printf(esc_html__('The database and web server clocks differ by %d seconds. Hold expiry may be inaccurate.', 'navarro-booking-for-woocommerce'), (int) $drift);
+                        ?></p>
+                    <?php endif; ?></td></tr>
+            <tr><th><?php esc_html_e('How Navarro Booking stores times', 'navarro-booking-for-woocommerce'); ?></th>
+                <td><?php esc_html_e('Every booking is stored as a UTC instant together with the site timezone at the time it was made. Customers see slots in the site timezone, with their own local time alongside when it differs.', 'navarro-booking-for-woocommerce'); ?></td></tr>
+            </tbody>
+        </table>
+
+        <h2><?php esc_html_e('Upcoming daylight-saving changes', 'navarro-booking-for-woocommerce'); ?></h2>
+        <?php
+        $transitions = $tz->getTransitions(time(), time() + YEAR_IN_SECONDS);
+        array_shift($transitions); // The first entry is the current state, not a change.
+        if (!$transitions) {
+            echo '<p>' . esc_html__('None in the next 12 months for this timezone.', 'navarro-booking-for-woocommerce') . '</p>';
+        } else {
+            echo '<ul>';
+            foreach ($transitions as $t) {
+                echo '<li>' . esc_html(wp_date('l, Y-m-d H:i', $t['ts'], $tz)) . ' — '
+                    /* translators: 1: abbreviation, 2: UTC offset in hours */
+                    . esc_html(sprintf(__('clocks change to %1$s (UTC%2$+.1f h)', 'navarro-booking-for-woocommerce'), $t['abbr'], $t['offset'] / 3600)) . '</li>';
+            }
+            echo '</ul>';
+        }
+        ?>
+
+        <h2><?php esc_html_e('Upcoming bookings', 'navarro-booking-for-woocommerce'); ?></h2>
+        <?php
+        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Navarro Booking's own table; core has no API for it, and a cached read could sell a seat twice.
+        $rows = $wpdb->get_results($wpdb->prepare(
+            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- bookings_table() is a prefix plus a literal; the date below is a placeholder.
+            'SELECT * FROM ' . Calentra_Install::bookings_table() . " WHERE end_utc >= %s AND status IN ('confirmed','hold') ORDER BY start_utc ASC LIMIT 50",
+            gmdate('Y-m-d H:i:s')
+        ));
+        if (!$rows) {
+            echo '<p>' . esc_html__('No upcoming bookings.', 'navarro-booking-for-woocommerce') . '</p>';
+        } else {
+            ?>
+            <div style="overflow-x:auto">
+            <table class="widefat striped calentra-diag">
+                <thead><tr>
+                    <th>#</th>
+                    <th><?php esc_html_e('Product', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Order', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Status', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Stored (UTC)', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Site time', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Your time', 'navarro-booking-for-woocommerce'); ?></th>
+                    <th><?php esc_html_e('Notes', 'navarro-booking-for-woocommerce'); ?></th>
+                </tr></thead>
+                <tbody>
+                <?php foreach ($rows as $row) :
+                    $btz = Calentra_Display::tz($row->site_timezone);
+                    $start = strtotime($row->start_utc . ' UTC');
+                    $end = strtotime($row->end_utc . ' UTC');
+                    $notes = self::notes($row, $btz, $tz);
+                    ?>
+                    <tr>
+                        <td><?php echo (int) $row->id; ?></td>
+                        <td><?php echo esc_html(get_the_title($row->product_id)); ?></td>
+                        <td><?php if ($row->order_id) : ?><a href="<?php echo esc_url(admin_url('admin.php?page=wc-orders&action=edit&id=' . (int) $row->order_id)); ?>">#<?php echo (int) $row->order_id; ?></a><?php else : ?>—<?php endif; ?></td>
+                        <td><?php echo esc_html($row->status . ($row->status === 'hold' ? ' → ' . $row->hold_expires . ' UTC' : '')); ?></td>
+                        <td><code><?php echo esc_html($row->start_utc); ?></code><br><code><?php echo esc_html($row->end_utc); ?></code></td>
+                        <td><?php echo esc_html(wp_date('Y-m-d H:i T', $start, $btz)); ?><br><?php echo esc_html(wp_date('Y-m-d H:i T', $end, $btz)); ?></td>
+                        <td class="calentra-viewer-time" data-start="<?php echo esc_attr(gmdate('c', $start)); ?>" data-end="<?php echo esc_attr(gmdate('c', $end)); ?>">…</td>
+                        <td><?php echo $notes ? '<span class="calentra-warn">⚠ ' . esc_html(implode(' ', $notes)) . '</span>' : ''; ?></td>
+                    </tr>
+                <?php endforeach; ?>
+                </tbody>
+            </table>
+            </div>
+            <?php
+        }
+        ?>
+        <h2><?php esc_html_e('Inventory', 'navarro-booking-for-woocommerce'); ?></h2>
+        <p><?php esc_html_e('Seat counters are kept alongside bookings so that two customers can never claim the last seat at once. If you have edited the database by hand, rebuild them from the bookings.', 'navarro-booking-for-woocommerce'); ?></p>
+        <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
+            <input type="hidden" name="action" value="calentra_rebuild_inventory">
+            <?php wp_nonce_field('calentra_rebuild_inventory'); ?>
+            <?php submit_button(__('Rebuild inventory counters', 'navarro-booking-for-woocommerce'), 'secondary', 'submit', false); ?>
+        </form>
+        <?php
+    }
+
+    /**
+     * Warnings for one booking: DST inside the booking, a DST change between now and the
+     * booking, and a site timezone that has changed since it was made.
+     */
+    private static function notes($row, DateTimeZone $booking_tz, DateTimeZone $site_tz) {
+        $notes = [];
+        $start = strtotime($row->start_utc . ' UTC');
+        $end = strtotime($row->end_utc . ' UTC');
+
+        // A transition inside the booking itself, or on the day it starts.
+        $day_start = (new DateTimeImmutable('@' . $start))->setTimezone($booking_tz)->setTime(0, 0)->getTimestamp();
+        $inside = $booking_tz->getTransitions(min($start, $day_start), max($end, $day_start + DAY_IN_SECONDS));
+        if (count($inside) > 1) {
+            $notes[] = __('A daylight-saving change falls on this booking\'s date: double-check the time with the customer.', 'navarro-booking-for-woocommerce');
+        }
+        // Different offset from today: the booked hour reads differently in UTC than it would now.
+        $now_offset = $booking_tz->getOffset(new DateTime('now'));
+        $then_offset = $booking_tz->getOffset(new DateTime('@' . $start));
+        if ($now_offset !== $then_offset) {
+            $notes[] = sprintf(
+                /* translators: %+.1f: hours */
+                __('Clocks change before this booking (offset %+.1f h); its UTC time differs from today\'s by that much. This is expected.', 'navarro-booking-for-woocommerce'),
+                ($then_offset - $now_offset) / 3600
+            );
+        }
+        if ($row->site_timezone !== $site_tz->getName()) {
+            $notes[] = sprintf(
+                /* translators: 1: old timezone, 2: new timezone */
+                __('Booked when the site timezone was %1$s; it is now %2$s. The booked instant is unchanged.', 'navarro-booking-for-woocommerce'),
+                $row->site_timezone,
+                $site_tz->getName()
+            );
+        }
+        return $notes;
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-display.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-display.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-display.php	(revision 3750436)
@@ -0,0 +1,114 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Human-readable booking lines for the cart, order screens and emails, and the order
+ * item labels. Times are always shown with their timezone so nobody has to guess.
+ */
+class Calentra_Display {
+
+    const META_DATE = 'calentra_booking_date';
+    const META_TIME = 'calentra_booking_time';
+    const META_END = 'calentra_booking_end_date';
+
+    private $in_admin_item_table = false;
+
+    public function init_hooks() {
+        add_filter('woocommerce_order_item_display_meta_key', [$this, 'label'], 10, 3);
+        add_filter('woocommerce_order_item_get_formatted_meta_data', [$this, 'hide_in_admin'], 10, 2);
+        add_action('woocommerce_before_order_itemmeta', function () {
+            $this->in_admin_item_table = true;
+        });
+        add_action('woocommerce_after_order_itemmeta', function () {
+            $this->in_admin_item_table = false;
+        }, 1);
+    }
+
+    public static function labels() {
+        return [
+            self::META_DATE => __('Booking Date', 'navarro-booking-for-woocommerce'),
+            self::META_TIME => __('Time Slot', 'navarro-booking-for-woocommerce'),
+            self::META_END  => __('End Date', 'navarro-booking-for-woocommerce'),
+        ];
+    }
+
+    /**
+     * @param array $b Booking identity: type, start_utc, end_utc, timezone.
+     * @return array<string, string> Label => value.
+     */
+    public static function lines(array $b) {
+        $values = self::values($b);
+        $out = [];
+        foreach (self::labels() as $key => $label) {
+            if (!empty($values[$key])) {
+                $out[$label] = $values[$key];
+            }
+        }
+        return $out;
+    }
+
+    private static function values(array $b) {
+        $tz = self::tz($b['timezone'] ?? '');
+        $start = strtotime($b['start_utc'] . ' UTC');
+        if ($b['type'] === 'multi-day') {
+            return [
+                self::META_DATE => wp_date(get_option('date_format'), $start, $tz),
+                self::META_END  => wp_date(get_option('date_format'), strtotime($b['end_utc'] . ' UTC'), $tz),
+            ];
+        }
+        return [
+            self::META_DATE => wp_date(get_option('date_format'), $start, $tz),
+            self::META_TIME => self::time_with_zone($start, $tz),
+        ];
+    }
+
+    /** "09:00 EDT", or "09:00 (UTC+08:00)" where the zone has no abbreviation. */
+    public static function time_with_zone($timestamp, DateTimeZone $tz) {
+        $abbr = wp_date('T', $timestamp, $tz);
+        if (preg_match('/^[+-]\d/', $abbr)) {
+            $abbr = '(UTC' . wp_date('P', $timestamp, $tz) . ')';
+        }
+        return wp_date(get_option('time_format') ?: 'H:i', $timestamp, $tz) . ' ' . $abbr;
+    }
+
+    public static function tz($name) {
+        try {
+            return $name ? new DateTimeZone($name) : wp_timezone();
+        } catch (Exception $e) {
+            return wp_timezone();
+        }
+    }
+
+    public static function write_item_meta(WC_Order_Item $item, array $b) {
+        $item->delete_meta_data(self::META_TIME);
+        $item->delete_meta_data(self::META_END);
+        foreach (self::values($b) as $key => $value) {
+            $item->update_meta_data($key, $value);
+        }
+        $item->update_meta_data('_calentra_booking_type', $b['type']);
+    }
+
+    public function label($display_key, $meta, $item) {
+        $labels = self::labels();
+        return $labels[$meta->key] ?? $display_key;
+    }
+
+    /**
+     * Inside the admin order items table the booking is shown, editable, by
+     * Calentra_Admin, so the read-only lines are hidden there. Only there: emails sent
+     * from an admin request must keep them, which a plain is_admin() check would break.
+     */
+    public function hide_in_admin($formatted_meta, $item) {
+        if (!$this->in_admin_item_table) {
+            return $formatted_meta;
+        }
+        $hide = array_merge(array_keys(self::labels()), ['Booking Date', 'Time Slot', 'End Date', 'Booking Type']);
+        foreach ($formatted_meta as $id => $meta) {
+            if (in_array($meta->key, $hide, true)) {
+                unset($formatted_meta[$id]);
+            }
+        }
+        return $formatted_meta;
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-frontend.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-frontend.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-frontend.php	(revision 3750436)
@@ -0,0 +1,106 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Product page: the booking picker and its assets.
+ */
+class Calentra_Frontend {
+
+    public function init_hooks() {
+        add_action('wp_enqueue_scripts', [$this, 'enqueue_scripts']);
+        add_action('woocommerce_before_add_to_cart_button', [$this, 'render_booking_fields']);
+        add_filter('woocommerce_loop_add_to_cart_link', [$this, 'force_booking'], 10, 2);
+        add_filter('woocommerce_is_sold_individually', [$this, 'hide_quantity'], 10, 2);
+    }
+
+    public function hide_quantity($sold_individually, $product) {
+        if ($product && $product->get_meta('_calentra_hide_quantity') === 'yes') {
+            return true;
+        }
+        return $sold_individually;
+    }
+
+    public function enqueue_scripts() {
+        // Booking assets are only needed on a product page that actually has
+        // booking switched on. Loading them everywhere made flatpickr throw on
+        // every other page, because the date input it looks for isn't there.
+        if (!function_exists('is_product') || !is_product()) {
+            return;
+        }
+        $config = Calentra_Rules::product_config(get_the_ID());
+        if (!$config['enabled']) {
+            return;
+        }
+
+        wp_enqueue_style('flatpickr', CALENTRA_PLUGIN_URL . 'assets/css/flatpickr.min.css', [], calentra_asset_version('assets/css/flatpickr.min.css'));
+        wp_enqueue_style('calentra-booking', CALENTRA_PLUGIN_URL . 'assets/css/style.css', [], calentra_asset_version('assets/css/style.css'));
+        wp_enqueue_script('flatpickr', CALENTRA_PLUGIN_URL . 'assets/js/flatpickr.min.js', [], calentra_asset_version('assets/js/flatpickr.min.js'), true);
+        wp_enqueue_script('calentra-booking', CALENTRA_PLUGIN_URL . 'assets/js/booking.js', ['jquery', 'flatpickr'], calentra_asset_version('assets/js/booking.js'), true);
+
+        // No nonce here on purpose: the availability endpoint is public read-only data,
+        // and a nonce baked into a cached page is what breaks booking pickers.
+        wp_localize_script('calentra-booking', 'calentra_booking', [
+            'rest_url'      => esc_url_raw(rest_url(Calentra_REST::NS . '/availability')),
+            'product_id'    => get_the_ID(),
+            'type'          => $config['type'],
+            'site_timezone' => $config['timezone']->getName(),
+            'max_days'      => (int) $config['max_days_ahead'],
+            'i18n'          => [
+                'loading'        => __('Loading…', 'navarro-booking-for-woocommerce'),
+                'select_slot'    => __('Select a time slot', 'navarro-booking-for-woocommerce'),
+                'no_slots'       => __('No available slots', 'navarro-booking-for-woocommerce'),
+                'your_time'      => __('your time', 'navarro-booking-for-woocommerce'),
+                'pick_date_time' => __('Please select a booking date and time.', 'navarro-booking-for-woocommerce'),
+                'pick_dates'     => __('Please select both start and end dates.', 'navarro-booking-for-woocommerce'),
+                'load_error'     => __('Availability could not be loaded. Please refresh the page.', 'navarro-booking-for-woocommerce'),
+                /* translators: %s: timezone name */
+                'times_in'       => __('Times shown in %s', 'navarro-booking-for-woocommerce'),
+                /* translators: %d: number of nights */
+                'nights'         => _n_noop('%d night', '%d nights', 'navarro-booking-for-woocommerce'),
+            ],
+        ]);
+    }
+
+    public function force_booking($button, $product) {
+        if ($product->get_meta('_calentra_booking_enabled') != 1 || $product->get_type() !== 'simple') {
+            return $button;
+        }
+        return sprintf(
+            '<a href="%s" class="button wp-element-button add_to_cart_button calentra-book-now">%s</a>',
+            esc_url($product->get_permalink()),
+            esc_html__('Book Now', 'navarro-booking-for-woocommerce')
+        );
+    }
+
+    public function render_booking_fields() {
+        global $product;
+        if (!$product) {
+            return;
+        }
+        $config = Calentra_Rules::product_config($product->get_id());
+        if (!$config['enabled']) {
+            return;
+        }
+        ?>
+        <div class="calentra-booking-fields" data-type="<?php echo esc_attr($config['type']); ?>">
+            <?php if ($config['type'] === 'timeslot') : ?>
+                <label for="calentra_booking_date"><?php esc_html_e('Booking Date', 'navarro-booking-for-woocommerce'); ?></label>
+                <input type="text" id="calentra_booking_date" name="calentra_booking_date" autocomplete="off" required>
+                <label for="calentra_booking_time"><?php esc_html_e('Time Slot', 'navarro-booking-for-woocommerce'); ?></label>
+                <select id="calentra_booking_time" name="calentra_booking_time" required>
+                    <option value=""><?php esc_html_e('Select a date first', 'navarro-booking-for-woocommerce'); ?></option>
+                </select>
+                <p class="calentra-tz-note" id="calentra_tz_note" aria-live="polite"></p>
+            <?php else : ?>
+                <label for="calentra_booking_date"><?php esc_html_e('Start Date', 'navarro-booking-for-woocommerce'); ?></label>
+                <input type="text" id="calentra_booking_date" name="calentra_booking_date" autocomplete="off" required>
+                <label for="calentra_booking_end_date"><?php esc_html_e('End Date', 'navarro-booking-for-woocommerce'); ?></label>
+                <input type="text" id="calentra_booking_end_date" name="calentra_booking_end_date" autocomplete="off" required>
+                <p class="calentra-nights" id="calentra_nights" aria-live="polite"></p>
+            <?php endif; ?>
+            <p class="calentra-error" id="calentra_error" role="alert" hidden></p>
+        </div>
+        <?php
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-ics.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-ics.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-ics.php	(revision 3750436)
@@ -0,0 +1,137 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Attaches an .ics invite for each confirmed booking to order emails.
+ *
+ * Built from the stored UTC instants, so the invite and the slot the customer picked can
+ * no longer disagree about the timezone. The files are written to the system temp
+ * directory and deleted at the end of the request, after the mail has gone.
+ */
+class Calentra_ICS {
+
+    private $tmp_files = [];
+
+    public function init_hooks() {
+        add_filter('woocommerce_email_attachments', [$this, 'attach'], 10, 3);
+        add_action('shutdown', [$this, 'cleanup']);
+    }
+
+    private static function email_ids() {
+        /**
+         * Filters which WooCommerce emails get .ics attachments.
+         *
+         * @param string[] $ids Email IDs.
+         */
+        return apply_filters('calentra_ics_email_ids', [
+            'new_order',
+            'customer_processing_order',
+            'customer_completed_order',
+            'customer_on_hold_order',
+            'customer_invoice',
+        ]);
+    }
+
+    public function attach($attachments, $email_id, $order) {
+        if (!is_a($order, 'WC_Order') || !in_array($email_id, self::email_ids(), true)) {
+            return $attachments;
+        }
+        foreach (Calentra_Bookings::for_order($order->get_id()) as $booking) {
+            if ($booking->status !== Calentra_Bookings::STATUS_CONFIRMED) {
+                continue;
+            }
+            $item = $booking->order_item_id ? $order->get_item($booking->order_item_id) : null;
+            $file = $this->write_file($booking, $item ? $item->get_name() : get_the_title($booking->product_id));
+            if ($file) {
+                $attachments[] = $file;
+            }
+        }
+        return $attachments;
+    }
+
+    public static function build($booking, $summary) {
+        /**
+         * Filters the event title in a booking's .ics invite.
+         *
+         * @param string $summary Default: the product (order item) name.
+         * @param object $booking The booking row.
+         */
+        $summary = (string) apply_filters('calentra_ics_summary', $summary, $booking);
+        $host = wp_parse_url(home_url(), PHP_URL_HOST) ?: 'localhost';
+        $site = wp_strip_all_tags(get_bloginfo('name'));
+        $start = strtotime($booking->start_utc . ' UTC');
+        $end = strtotime($booking->end_utc . ' UTC');
+
+        if ($booking->booking_type === 'multi-day') {
+            // All-day events are floating dates; use the site-local calendar dates.
+            $tz = Calentra_Display::tz($booking->site_timezone);
+            $dtstart = 'DTSTART;VALUE=DATE:' . wp_date('Ymd', $start, $tz);
+            $dtend = 'DTEND;VALUE=DATE:' . wp_date('Ymd', $end, $tz);
+        } else {
+            $dtstart = 'DTSTART:' . gmdate('Ymd\THis\Z', $start);
+            $dtend = 'DTEND:' . gmdate('Ymd\THis\Z', $end);
+        }
+
+        $lines = [
+            'BEGIN:VCALENDAR',
+            'VERSION:2.0',
+            'PRODID:-//Dominick Navarro//Navarro Booking ' . CALENTRA_VERSION . '//EN',
+            'CALSCALE:GREGORIAN',
+            'METHOD:PUBLISH',
+            'BEGIN:VEVENT',
+            // Stable per booking, so a re-sent email updates the event instead of duplicating it.
+            'UID:calentra-' . (int) $booking->id . '@' . $host,
+            'DTSTAMP:' . gmdate('Ymd\THis\Z'),
+            $dtstart,
+            $dtend,
+            'SUMMARY:' . self::escape($summary),
+            /* translators: 1: site name, 2: product name */
+            'DESCRIPTION:' . self::escape(sprintf(__('Booking from %1$s for %2$s', 'navarro-booking-for-woocommerce'), $site, $summary)),
+            'END:VEVENT',
+            'END:VCALENDAR',
+        ];
+        return implode("\r\n", array_map([__CLASS__, 'fold'], $lines)) . "\r\n";
+    }
+
+    private function write_file($booking, $summary) {
+        $path = trailingslashit(get_temp_dir()) . 'calentra-booking-' . (int) $booking->id . '-' . wp_generate_password(8, false) . '.ics';
+        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file, removed at shutdown.
+        if (false === file_put_contents($path, self::build($booking, $summary))) {
+            return null;
+        }
+        $this->tmp_files[] = $path;
+        return $path;
+    }
+
+    public function cleanup() {
+        foreach ($this->tmp_files as $file) {
+            wp_delete_file($file);
+        }
+        $this->tmp_files = [];
+    }
+
+    /** RFC 5545 TEXT escaping. */
+    private static function escape($text) {
+        $text = wp_strip_all_tags((string) $text);
+        return str_replace(["\\", ';', ',', "\r\n", "\n"], ["\\\\", '\;', '\,', '\n', '\n'], $text);
+    }
+
+    /** RFC 5545 line folding at 75 octets. */
+    private static function fold($line) {
+        if (strlen($line) <= 75) {
+            return $line;
+        }
+        $out = '';
+        while (strlen($line) > 75) {
+            $cut = 75;
+            // Don't split a multibyte character.
+            while ($cut > 0 && (ord($line[$cut]) & 0xC0) === 0x80) {
+                $cut--;
+            }
+            $out .= substr($line, 0, $cut) . "\r\n ";
+            $line = substr($line, $cut);
+        }
+        return $out . $line;
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-install.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-install.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-install.php	(revision 3750436)
@@ -0,0 +1,350 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/*
+ * Schema work: creating, renaming and migrating Navarro Booking's own tables. WordPress has
+ * no API for any of it, so the direct-query, caching and schema-change sniffs are
+ * switched off for the file, on the same invariant used in class-calentra-bookings.php:
+ * table names come only from this class's own bookings_table() / inventory_table()
+ * helpers, which are $wpdb->prefix plus a literal, and every value is a placeholder
+ * passed through $wpdb->prepare(). Caching a migration would be meaningless.
+ */
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
+// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
+// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
+// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
+
+/**
+ * Schema and upgrades.
+ *
+ * v1.1.0 stored bookings as a local DATE + TIME with no status. 2.0 stores UTC instants,
+ * a status (hold | confirmed | cancelled | expired) and a seat count, and keeps a
+ * per-slot inventory counter so claims can be made atomically.
+ */
+class Calentra_Install {
+
+    const DB_VERSION = '2.4.0';
+    const DB_VERSION_OPTION = 'calentra_db_version';
+
+    public static function bookings_table() {
+        global $wpdb;
+        return $wpdb->prefix . 'calentra_bookings';
+    }
+
+    public static function inventory_table() {
+        global $wpdb;
+        return $wpdb->prefix . 'calentra_slot_inventory';
+    }
+
+    public static function legacy_table() {
+        global $wpdb;
+        return $wpdb->prefix . 'calentra_bookings_v1';
+    }
+
+    public static function install() {
+        if (!class_exists('WooCommerce')) {
+            return; // Runs on the first request after WooCommerce is active instead.
+        }
+        Calentra_Loader::includes();
+        self::maybe_upgrade();
+    }
+
+    public static function maybe_upgrade() {
+        if (get_option(self::DB_VERSION_OPTION) === self::DB_VERSION) {
+            return;
+        }
+
+        // Two requests can land here at once right after an update; only one may migrate.
+        if (!add_option('calentra_upgrade_lock', time(), '', false)) {
+            if (time() - (int) get_option('calentra_upgrade_lock') < 300) {
+                return;
+            }
+            update_option('calentra_upgrade_lock', time(), false);
+        }
+
+        $previous = (string) get_option(self::DB_VERSION_OPTION);
+        $had_legacy = self::retire_legacy_table();
+        // retire_legacy_table() is also true whenever the v1 backup table is still there,
+        // long after it was migrated; only a migration that actually runs rebuilds.
+        $migrating_v1 = $had_legacy && !get_option('calentra_v1_migrated');
+        self::create_tables();
+        if ($had_legacy) {
+            self::migrate_from_v1();
+        }
+        if (!$migrating_v1 && $previous !== '' && version_compare($previous, '2.4.0', '<')) {
+            // A v1 migration's rebuild_inventory() writes the resource-wide rows itself.
+            self::backfill_resource_inventory();
+        }
+
+        update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
+        delete_option('calentra_upgrade_lock');
+    }
+
+    private static function create_tables() {
+        global $wpdb;
+        $bookings = self::bookings_table();
+        $inventory = self::inventory_table();
+        $charset_collate = $wpdb->get_charset_collate();
+
+        $sql = "CREATE TABLE $bookings (
+            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+            product_id BIGINT UNSIGNED NOT NULL,
+            resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
+            booking_type VARCHAR(20) NOT NULL DEFAULT 'timeslot',
+            order_id BIGINT UNSIGNED NULL,
+            order_item_id BIGINT UNSIGNED NULL,
+            status VARCHAR(20) NOT NULL,
+            start_utc DATETIME NOT NULL,
+            end_utc DATETIME NOT NULL,
+            site_timezone VARCHAR(64) NOT NULL,
+            seats INT UNSIGNED NOT NULL DEFAULT 1,
+            hold_expires DATETIME NULL,
+            session_key VARCHAR(64) NULL,
+            created_at DATETIME NOT NULL,
+            updated_at DATETIME NOT NULL,
+            PRIMARY KEY  (id),
+            KEY idx_lookup (product_id,resource_id,start_utc,status),
+            KEY idx_holds (status,hold_expires),
+            KEY idx_order (order_id),
+            KEY idx_session (session_key),
+            KEY idx_resource (resource_id,start_utc)
+        ) $charset_collate;
+        CREATE TABLE $inventory (
+            product_id BIGINT UNSIGNED NOT NULL,
+            resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
+            start_utc DATETIME NOT NULL,
+            seats_total INT UNSIGNED NOT NULL,
+            seats_used INT UNSIGNED NOT NULL DEFAULT 0,
+            PRIMARY KEY  (product_id,resource_id,start_utc)
+        ) $charset_collate;";
+
+        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+        dbDelta($sql);
+    }
+
+    /**
+     * Moves a v1.1.0 table (DATE + TIME columns) aside so the new one can be created.
+     * The old table is kept as a backup and removed on uninstall.
+     */
+    private static function retire_legacy_table() {
+        global $wpdb;
+        $table = self::bookings_table();
+        $legacy = self::legacy_table();
+
+        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $legacy)) === $legacy) {
+            // A previous migration was interrupted after the rename; finish it.
+            return true;
+        }
+        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) {
+            return false;
+        }
+        $has_old_column = $wpdb->get_var($wpdb->prepare(
+            "SHOW COLUMNS FROM `$table` LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+            'booking_date'
+        ));
+        if (!$has_old_column) {
+            return false;
+        }
+        $wpdb->query("RENAME TABLE `$table` TO `$legacy`"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+        return true;
+    }
+
+    /**
+     * Backfills 2.0 rows from two v1.1.0 sources:
+     *  - the old bookings table (orders placed before the multi-day release), and
+     *  - order line item meta. From the multi-day release onwards, checkout saved the
+     *    booking under "Booking Date" / "Time Slot" / "End Date", while store_booking()
+     *    read "calentra_booking_date", so those bookings never reached the table at all.
+     *
+     * Local times are interpreted in the site timezone, which is what v1.1.0 meant by them.
+     */
+    private static function migrate_from_v1() {
+        global $wpdb;
+        $legacy = self::legacy_table();
+        $tz = wp_timezone();
+        $seen = [];
+
+        if (get_option('calentra_v1_migrated')) {
+            return;
+        }
+
+        $rows = $wpdb->get_results("SELECT product_id, booking_date, booking_time, order_id FROM `$legacy`");
+        foreach ((array) $rows as $row) {
+            $key = $row->order_id . ':' . $row->product_id;
+            $seen[$key] = true;
+            self::insert_migrated($row->product_id, (int) $row->order_id, 0, 'timeslot', $row->booking_date, substr($row->booking_time, 0, 5), null, $tz);
+        }
+
+        // Line items carrying booking meta under either the v1.0 or v1.1 key names.
+        $items = $wpdb->get_results(
+            "SELECT i.order_item_id, i.order_id,
+                    MAX(CASE WHEN m.meta_key IN ('Booking Date','calentra_booking_date') THEN m.meta_value END) AS bdate,
+                    MAX(CASE WHEN m.meta_key IN ('Time Slot','calentra_booking_time') THEN m.meta_value END) AS btime,
+                    MAX(CASE WHEN m.meta_key = 'End Date' THEN m.meta_value END) AS bend,
+                    MAX(CASE WHEN m.meta_key = 'Booking Type' THEN m.meta_value END) AS btype,
+                    MAX(CASE WHEN m.meta_key = '_product_id' THEN m.meta_value END) AS product_id
+               FROM {$wpdb->prefix}woocommerce_order_items i
+               JOIN {$wpdb->prefix}woocommerce_order_itemmeta m ON m.order_item_id = i.order_item_id
+              WHERE i.order_item_type = 'line_item'
+           GROUP BY i.order_item_id, i.order_id
+             HAVING bdate IS NOT NULL"
+        );
+        foreach ((array) $items as $item) {
+            $key = $item->order_id . ':' . $item->product_id;
+            if (isset($seen[$key])) {
+                $wpdb->update(self::bookings_table(), ['order_item_id' => $item->order_item_id], ['order_id' => $item->order_id, 'product_id' => $item->product_id]);
+                $booking_id = $wpdb->get_var($wpdb->prepare(
+                    'SELECT id FROM ' . self::bookings_table() . ' WHERE order_id = %d AND product_id = %d',
+                    $item->order_id, $item->product_id
+                ));
+                if ($booking_id) {
+                    wc_update_order_item_meta($item->order_item_id, '_calentra_booking_id', (int) $booking_id);
+                }
+                continue;
+            }
+            $type = $item->btype === 'multi-day' ? 'multi-day' : 'timeslot';
+            self::insert_migrated($item->product_id, (int) $item->order_id, (int) $item->order_item_id, $type, $item->bdate, $item->btime, $item->bend, $tz);
+        }
+
+        // v1.1.0 hooked save_post for every post type, so pages and posts saved since
+        // then carry empty booking meta. Only products should.
+        $wpdb->query(
+            "DELETE m FROM {$wpdb->postmeta} m JOIN {$wpdb->posts} p ON p.ID = m.post_id
+              WHERE m.meta_key LIKE '\\_calentra\\_%' AND p.post_type NOT IN ('product', 'product_variation')"
+        );
+
+        self::rebuild_inventory();
+        update_option('calentra_v1_migrated', 1, false);
+    }
+
+    private static function insert_migrated($product_id, $order_id, $item_id, $type, $date, $time, $end_date, DateTimeZone $tz) {
+        global $wpdb;
+        try {
+            if ($type === 'multi-day') {
+                if (!$end_date) {
+                    return;
+                }
+                $start = new DateTimeImmutable($date . ' 00:00', $tz);
+                $end = new DateTimeImmutable($end_date . ' 00:00', $tz);
+                if ($end <= $start) {
+                    $end = $start->modify('+1 day');
+                }
+            } else {
+                if (!$time) {
+                    return;
+                }
+                $start = new DateTimeImmutable($date . ' ' . $time, $tz);
+                $duration = (int) get_post_meta($product_id, '_calentra_duration', true) ?: 60;
+                $end = $start->modify("+{$duration} minutes");
+            }
+        } catch (Exception $e) {
+            return;
+        }
+
+        $status = 'confirmed';
+        $order = $order_id ? wc_get_order($order_id) : null;
+        if ($order && in_array($order->get_status(), ['cancelled', 'failed', 'refunded'], true)) {
+            $status = 'cancelled';
+        }
+
+        $utc = new DateTimeZone('UTC');
+        $now = gmdate('Y-m-d H:i:s');
+        $wpdb->insert(self::bookings_table(), [
+            'product_id'    => (int) $product_id,
+            'resource_id'   => 0,
+            'booking_type'  => $type,
+            'order_id'      => $order_id ?: null,
+            'order_item_id' => $item_id ?: null,
+            'status'        => $status,
+            'start_utc'     => $start->setTimezone($utc)->format('Y-m-d H:i:s'),
+            'end_utc'       => $end->setTimezone($utc)->format('Y-m-d H:i:s'),
+            'site_timezone' => $tz->getName(),
+            'seats'         => 1,
+            'created_at'    => $now,
+            'updated_at'    => $now,
+        ]);
+        if ($item_id && $wpdb->insert_id) {
+            wc_update_order_item_meta($item_id, '_calentra_booking_id', $wpdb->insert_id);
+        }
+    }
+
+    /**
+     * Recomputes every inventory counter from the bookings that hold seats: confirmed
+     * ones and every hold, lapsed or not. Used after migration and available from the
+     * diagnostics screen.
+     *
+     * A lapsed hold is still counted until it is retired, and retiring it gives its seats
+     * back (Calentra_Bookings::transition()). Leaving it out here would let that later
+     * give-back remove a seat that was never counted, so the counter would fall below
+     * the bookings it covers and the slot could be oversold.
+     */
+    public static function rebuild_inventory() {
+        global $wpdb;
+        $wpdb->query('DELETE FROM ' . self::inventory_table());
+        $rows = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM %i WHERE status = 'confirmed' OR status = 'hold'",
+            self::bookings_table()
+        ));
+        foreach ((array) $rows as $row) {
+            foreach (Calentra_Bookings::units_for($row) as $unit) {
+                self::add_seats($row->product_id, $row->resource_id, $unit, $row->seats);
+            }
+            if ((int) $row->resource_id > 0) {
+                foreach ((array) Calentra_Bookings::resource_units_for($row) as $unit) {
+                    self::add_seats(0, $row->resource_id, $unit, $row->seats);
+                }
+            }
+        }
+    }
+
+    /**
+     * 2.4.0: writes the resource-wide rows (product_id = 0) that bookings on a resource
+     * made before 2.4.0 never claimed.
+     *
+     * Adds to the rows rather than setting them. Requests already running 2.4.0 claim
+     * shared rows while this runs, and a claim takes its seat before its booking row
+     * exists. Setting a row to the sum of bookings would drop such an in-flight seat,
+     * an oversell. Adding can only count a booking made by 2.4.0 in this window twice,
+     * a seat that stays unsellable until "Rebuild inventory" on the diagnostics screen
+     * recomputes it. A perfectly clean upgrade means pausing the shop, or running the
+     * upgrade with WP-CLI before traffic reaches it.
+     *
+     * calentra_upgrade_lock in maybe_upgrade() keeps two of these from running at once.
+     *
+     * Only bookings that have not ended: a past slot is never claimed again, and a
+     * give-back on a row that doesn't exist changes nothing. Every hold is counted,
+     * lapsed or not, for the reason given in rebuild_inventory().
+     */
+    private static function backfill_resource_inventory() {
+        global $wpdb;
+        $rows = $wpdb->get_results($wpdb->prepare(
+            "SELECT * FROM %i WHERE resource_id > 0 AND end_utc > %s AND (status = 'confirmed' OR status = 'hold')",
+            self::bookings_table(), gmdate('Y-m-d H:i:s')
+        ));
+        $seats = [];
+        foreach ((array) $rows as $row) {
+            // Multi-day bookings occupy one unit per night, as units_for() expands them.
+            foreach ((array) Calentra_Bookings::resource_units_for($row) as $unit) {
+                $key = (int) $row->resource_id . '|' . $unit;
+                $seats[$key] = ($seats[$key] ?? 0) + (int) $row->seats;
+            }
+        }
+        foreach ($seats as $key => $count) {
+            [$resource_id, $unit] = explode('|', $key, 2);
+            self::add_seats(0, (int) $resource_id, $unit, $count);
+        }
+    }
+
+    private static function add_seats($product_id, $resource_id, $unit, $seats) {
+        global $wpdb;
+        $capacity = Calentra_Availability::capacity($product_id, $resource_id, $unit);
+        $wpdb->query($wpdb->prepare(
+            'INSERT INTO %i (product_id, resource_id, start_utc, seats_total, seats_used) VALUES (%d, %d, %s, %d, %d)
+             ON DUPLICATE KEY UPDATE seats_used = seats_used + VALUES(seats_used)',
+            self::inventory_table(), $product_id, $resource_id, $unit, $capacity, $seats
+        ));
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-loader.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-loader.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-loader.php	(revision 3750436)
@@ -0,0 +1,52 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+class Calentra_Loader {
+
+    public static function includes() {
+        foreach ([
+            'class-calentra-rules.php',
+            'class-calentra-availability.php',
+            'class-calentra-bookings.php',
+            'class-calentra-display.php',
+            'class-calentra-rest.php',
+            'class-calentra-cart.php',
+            'class-calentra-ics.php',
+            'class-calentra-frontend.php',
+            'class-calentra-admin.php',
+            'class-calentra-diagnostics.php',
+            'class-calentra-upgrade.php',
+        ] as $file) {
+            require_once CALENTRA_PLUGIN_PATH . 'includes/' . $file;
+        }
+    }
+
+    public function run() {
+        self::includes();
+
+        // No load_plugin_textdomain() call: WordPress has loaded translations for a
+        // plugin's own text domain automatically since 4.6, and calling it by hand is
+        // now flagged by Plugin Check.
+
+        // Cart, checkout, REST and order-status hooks run in every context: the block
+        // checkout goes through the REST API and statuses usually change in the admin.
+        (new Calentra_REST())->init_hooks();
+        (new Calentra_Cart())->init_hooks();
+        (new Calentra_Display())->init_hooks();
+        (new Calentra_ICS())->init_hooks();
+
+        if (is_admin()) {
+            (new Calentra_Admin())->init_hooks();
+            (new Calentra_Upgrade())->init_hooks();
+        } else {
+            (new Calentra_Frontend())->init_hooks();
+        }
+
+        // Optional tidy-up of lapsed holds. Nothing depends on it running.
+        add_action('calentra_sweep', ['Calentra_Bookings', 'sweep']);
+        if (!wp_next_scheduled('calentra_sweep')) {
+            wp_schedule_event(time() + HOUR_IN_SECONDS, 'daily', 'calentra_sweep');
+        }
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-rest.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-rest.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-rest.php	(revision 3750436)
@@ -0,0 +1,103 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Public, nonce-free, GET availability endpoint.
+ *
+ * Availability is public read-only data, so it needs no nonce, and without one there is
+ * nothing for a page cache to freeze: a cached product page keeps working for as long
+ * as it stays cached. Responses carry an ETag and a short Cache-Control.
+ *
+ * GET /wp-json/calentra/v1/availability?product_id=12&from=2026-10-01&to=2026-10-31[&resource_id=3]
+ */
+class Calentra_REST {
+
+    const NS = 'calentra/v1';
+    const MAX_AGE = 30;
+
+    public function init_hooks() {
+        add_action('rest_api_init', [$this, 'register_routes']);
+    }
+
+    public function register_routes() {
+        register_rest_route(self::NS, '/availability', [
+            'methods'             => WP_REST_Server::READABLE,
+            'callback'            => [$this, 'availability'],
+            'permission_callback' => '__return_true',
+            'args'                => [
+                'product_id'  => ['required' => true, 'type' => 'integer', 'minimum' => 1],
+                'from'        => ['required' => true, 'type' => 'string', 'pattern' => '^\d{4}-\d{2}-\d{2}$'],
+                'to'          => ['required' => true, 'type' => 'string', 'pattern' => '^\d{4}-\d{2}-\d{2}$'],
+                // 0 is the product itself. Which resources belong to a product is an
+                // add-on's business; core answers for whichever resource it is asked about.
+                'resource_id' => ['required' => false, 'type' => 'integer', 'minimum' => 0, 'default' => 0],
+            ],
+        ]);
+    }
+
+    public function availability(WP_REST_Request $request) {
+        $product_id = (int) $request['product_id'];
+        $product = wc_get_product($product_id);
+        if (!$product || $product->get_status() !== 'publish') {
+            return new WP_Error('calentra_not_found', __('Product not found.', 'navarro-booking-for-woocommerce'), ['status' => 404]);
+        }
+
+        $session_key = self::session_key_from_cookie();
+        $resource_id = (int) $request['resource_id'];
+        $args = ['session_key' => $session_key, 'resource_id' => $resource_id];
+
+        if ($session_key === '') {
+            // Anonymous responses are identical for everyone, so they can be cached
+            // server-side until a booking or a rule changes (or the TTL lapses, which
+            // covers slots passing and holds expiring).
+            $cache_key = 'calentra_av_' . md5(implode('|', [
+                // Each resource has its own bookings, so it needs its own cache entry.
+                $product_id, $resource_id, $request['from'], $request['to'],
+                Calentra_Availability::cache_version($product_id),
+                (int) get_option('calentra_global_cache_version'),
+            ]));
+            $data = get_transient($cache_key);
+            if ($data === false) {
+                $data = Calentra_Availability::get($product_id, $request['from'], $request['to'], $args);
+                if (!is_wp_error($data)) {
+                    set_transient($cache_key, $data, self::MAX_AGE);
+                }
+            }
+        } else {
+            $data = Calentra_Availability::get($product_id, $request['from'], $request['to'], $args);
+        }
+
+        if (is_wp_error($data)) {
+            return $data;
+        }
+
+        $etag = '"' . md5(wp_json_encode($data)) . '"';
+        $response = new WP_REST_Response($data);
+        $response->header('ETag', $etag);
+        $response->header('Cache-Control', $session_key === ''
+            ? 'public, max-age=' . self::MAX_AGE
+            : 'private, no-cache');
+        $response->header('Vary', 'Cookie');
+
+        $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim(sanitize_text_field(wp_unslash($_SERVER['HTTP_IF_NONE_MATCH']))) : '';
+        if ($if_none_match !== '' && $if_none_match === $etag) {
+            $response->set_status(304);
+            $response->set_data(null);
+        }
+        return $response;
+    }
+
+    /**
+     * The WooCommerce cart session from its cookie, verified. REST requests do not load
+     * the Woo session, and we only need its key to let a customer see their own holds.
+     */
+    public static function session_key_from_cookie() {
+        if (!class_exists('WC_Session_Handler')) {
+            return '';
+        }
+        $handler = new WC_Session_Handler();
+        $cookie = $handler->get_session_cookie();
+        return $cookie ? (string) $cookie[0] : '';
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-rules.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-rules.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-rules.php	(revision 3750436)
@@ -0,0 +1,133 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Reads, parses and validates booking configuration. Everything that decides what a
+ * product's bookable slots are goes through here, so the availability read path and the
+ * add-to-cart / checkout write paths agree by construction.
+ */
+class Calentra_Rules {
+
+    const DEFAULT_DAYS_AHEAD = 365;
+    const MAX_DAYS_AHEAD = 1825;
+
+    /**
+     * Effective configuration for a product: product meta layered over global options.
+     */
+    public static function product_config($product_id) {
+        $product_id = (int) $product_id;
+        $config = [
+            'enabled'       => '1' === (string) get_post_meta($product_id, '_calentra_booking_enabled', true),
+            'type'          => get_post_meta($product_id, '_calentra_booking_type', true) === 'multi-day' ? 'multi-day' : 'timeslot',
+            'start'         => self::valid_time(get_post_meta($product_id, '_calentra_start_time', true)) ?: (self::valid_time(get_option('calentra_global_start')) ?: '09:00'),
+            'end'           => self::valid_time(get_post_meta($product_id, '_calentra_end_time', true)) ?: (self::valid_time(get_option('calentra_global_end')) ?: '17:00'),
+            'duration'      => max(1, (int) get_post_meta($product_id, '_calentra_duration', true) ?: 60),
+            'blocked_dates' => array_values(array_unique(array_merge(
+                self::parse_dates(get_post_meta($product_id, '_calentra_blocked_dates', true))['valid'],
+                self::parse_dates(get_option('calentra_global_blocked_dates', []))['valid']
+            ))),
+            'blocked_slots' => array_values(array_unique(array_merge(
+                self::parse_slots(get_post_meta($product_id, '_calentra_blocked_slots', true))['valid'],
+                self::parse_slots(get_option('calentra_global_blocked_slots', []))['valid']
+            ))),
+            'timezone'      => wp_timezone(),
+            // How far ahead customers may book. Without a limit a crafted request could
+            // ask for a slot in the year 9999.
+            'max_days_ahead' => self::max_days_ahead($product_id),
+        ];
+
+        /**
+         * Filters a product's effective booking configuration.
+         *
+         * @param array $config     See Calentra_Rules::product_config().
+         * @param int   $product_id Product ID.
+         */
+        return apply_filters('calentra_product_config', $config, $product_id);
+    }
+
+    /**
+     * Booking horizon in days: the product's own setting, else the global default.
+     */
+    public static function max_days_ahead($product_id) {
+        $days = self::clamp_days_ahead(get_post_meta($product_id, '_calentra_max_days_ahead', true));
+        if (!$days) {
+            $days = self::clamp_days_ahead(get_option('calentra_max_days_ahead', self::DEFAULT_DAYS_AHEAD)) ?: self::DEFAULT_DAYS_AHEAD;
+        }
+        return $days;
+    }
+
+    /** 1 to MAX_DAYS_AHEAD, or 0 for "not set". */
+    public static function clamp_days_ahead($value) {
+        $days = absint($value);
+        return $days ? min($days, self::MAX_DAYS_AHEAD) : 0;
+    }
+
+    public static function valid_time($value) {
+        $value = is_string($value) ? trim($value) : '';
+        if (preg_match('/^([01]\d|2[0-3]):([0-5]\d)$/', $value)) {
+            return $value;
+        }
+        return '';
+    }
+
+    public static function valid_date($value) {
+        $value = is_string($value) ? trim($value) : '';
+        if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m)) {
+            return '';
+        }
+        return checkdate((int) $m[2], (int) $m[3], (int) $m[1]) ? $value : '';
+    }
+
+    /**
+     * Accepts an array or the comma-separated string v1.1.0 stored.
+     *
+     * @return array{valid: string[], invalid: string[]}
+     */
+    public static function parse_dates($raw) {
+        $out = ['valid' => [], 'invalid' => []];
+        foreach (self::split($raw) as $entry) {
+            if (self::valid_date($entry)) {
+                $out['valid'][] = $entry;
+            } else {
+                $out['invalid'][] = $entry;
+            }
+        }
+        $out['valid'] = array_values(array_unique($out['valid']));
+        sort($out['valid']);
+        return $out;
+    }
+
+    /**
+     * Blocked slots are "YYYY-MM-DD HH:MM" in site time.
+     *
+     * @return array{valid: string[], invalid: string[]}
+     */
+    public static function parse_slots($raw) {
+        $out = ['valid' => [], 'invalid' => []];
+        foreach (self::split($raw) as $entry) {
+            $entry = preg_replace('/\s+/', ' ', $entry);
+            $parts = explode(' ', $entry);
+            if (count($parts) === 2 && self::valid_date($parts[0]) && self::valid_time($parts[1])) {
+                $out['valid'][] = $entry;
+            } else {
+                $out['invalid'][] = $entry;
+            }
+        }
+        $out['valid'] = array_values(array_unique($out['valid']));
+        sort($out['valid']);
+        return $out;
+    }
+
+    private static function split($raw) {
+        if (is_array($raw)) {
+            $items = $raw;
+        } else {
+            $items = explode(',', (string) $raw);
+        }
+        $items = array_map(function ($v) {
+            return trim(sanitize_text_field((string) $v));
+        }, $items);
+        return array_values(array_filter($items, 'strlen'));
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/includes/class-calentra-upgrade.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/includes/class-calentra-upgrade.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/includes/class-calentra-upgrade.php	(revision 3750436)
@@ -0,0 +1,233 @@
+<?php
+
+defined('ABSPATH') || exit;
+
+/**
+ * Where Navarro Booking Pro's features would be, shown in place on Navarro Booking's own
+ * screens, and explained only when clicked.
+ *
+ * One registry drives every teaser: a feature has an id, a title (the real label of the
+ * control it stands in for), a one-sentence blurb and a location. A new teaser is a new
+ * entry; render() at an existing location picks it up.
+ *
+ * Rules this class keeps: nothing opens unless clicked (a <details> element, no script);
+ * no admin notices, no dashboard widget, no prompt on activation; a locked control is
+ * never an input, so it can't look as if it accepts a value; no telemetry.
+ *
+ * "Learn more" goes to the calentra_upgrade_url option when it is set, and otherwise to
+ * the Navarro Booking Pro page. This plugin bundles no licensing or update code: Pro is
+ * a separate plugin, bought and licensed there.
+ */
+class Calentra_Upgrade {
+
+    /** Overrides where "learn more" goes. Never written by this plugin; unset by default. */
+    const URL_OPTION = 'calentra_upgrade_url';
+
+    /** The Navarro Booking Pro page, where it is described and sold. */
+    const PRO_URL = 'https://dominicknavarro.com/navarro-booking-pro';
+
+    /** Anchor of the Pro row at the foot of the settings page. */
+    const ANCHOR = 'calentra-upgrade';
+
+    /** Product booking panel, below the start and end times. */
+    const LOCATION_PRODUCT_TIMES = 'product_times';
+    /** Product booking panel, below the duration. */
+    const LOCATION_PRODUCT_CAPACITY = 'product_capacity';
+    /** Product booking panel, at the end. */
+    const LOCATION_PRODUCT_END = 'product_end';
+    /** Settings page, after the built-in settings. */
+    const LOCATION_SETTINGS = 'settings';
+    /** WooCommerce → Bookings, beside the page title. */
+    const LOCATION_BOOKINGS_ACTIONS = 'bookings_actions';
+
+    public function init_hooks() {
+        add_filter('plugin_action_links_' . plugin_basename(CALENTRA_PLUGIN_FILE), [$this, 'action_links']);
+        add_action('admin_enqueue_scripts', [$this, 'enqueue']);
+    }
+
+    /**
+     * The registry, in display order within each location. Built on each call so the
+     * strings are translated in the current locale.
+     *
+     * Only features Navarro Booking Pro actually has belong here: each entry says "Part
+     * of Navarro Booking Pro". A feature gets its entry when its Pro release ships.
+     *
+     * @return array<string, array{title: string, blurb: string, location: string}>
+     */
+    public static function features() {
+        return [
+            'resources' => [
+                'title'    => __('Resources', 'navarro-booking-for-woocommerce'),
+                'blurb'    => __('Book staff, rooms or equipment, chosen by the customer or assigned to whichever is free.', 'navarro-booking-for-woocommerce'),
+                'location' => self::LOCATION_PRODUCT_END,
+            ],
+        ];
+    }
+
+    /**
+     * Whether a feature is provided, so its teaser must not appear. Navarro Booking Pro
+     * answers for the features it actually has; nothing else in this plugin decides it.
+     */
+    public static function unlocked($id) {
+        /**
+         * Filters whether a feature shown as locked in the free plugin is available.
+         *
+         * @param bool   $unlocked Default false.
+         * @param string $id       Feature id from Calentra_Upgrade::features().
+         */
+        return (bool) apply_filters('calentra_feature_unlocked', false, (string) $id);
+    }
+
+    /**
+     * Where "learn more" goes, or '' for nowhere.
+     */
+    public static function url() {
+        $url = (string) get_option(self::URL_OPTION, '');
+
+        if ($url === '') {
+            $url = self::PRO_URL;
+        }
+
+        /**
+         * Filters the URL that explains Navarro Booking Pro.
+         *
+         * @param string $url The calentra_upgrade_url option, or the Navarro Booking Pro
+         *                    page when that option is unset. Return '' for no link.
+         */
+        $url = (string) apply_filters('calentra_upgrade_url', $url);
+
+        return $url === '' ? '' : esc_url_raw($url, ['https', 'http']);
+    }
+
+    /**
+     * Whether a URL stays inside this site's admin, in which case the link must not open
+     * a new tab: a new tab for an internal screen is a nuisance, not a courtesy.
+     */
+    private static function is_internal($url) {
+        return $url !== '' && strpos($url, admin_url()) === 0;
+    }
+
+    /**
+     * Features still locked, optionally only those at one location.
+     *
+     * @return array<string, array>
+     */
+    public static function locked($location = '') {
+        $locked = [];
+        foreach (self::features() as $id => $feature) {
+            if (($location === '' || $feature['location'] === $location) && !self::unlocked($id)) {
+                $locked[$id] = $feature;
+            }
+        }
+        return $locked;
+    }
+
+    /**
+     * Renders every locked feature registered at a location.
+     */
+    public static function render($location) {
+        $locked = self::locked($location);
+        if (!$locked) {
+            return;
+        }
+        $class = 'calentra-locks calentra-locks--' . $location;
+        echo '<div class="' . esc_attr($class) . '">';
+        foreach (array_keys($locked) as $id) {
+            self::lock($id);
+        }
+        echo '</div>';
+    }
+
+    /**
+     * The locked control for one feature: its real label and a lock, and nothing more
+     * until clicked. Not a form control, so it never submits or appears to hold a value.
+     */
+    public static function lock($id) {
+        $features = self::features();
+        if (!isset($features[$id]) || self::unlocked($id)) {
+            return;
+        }
+        $feature = $features[$id];
+        ?>
+        <details class="calentra-lock" data-calentra-feature="<?php echo esc_attr($id); ?>">
+            <summary><span class="dashicons dashicons-lock" aria-hidden="true"></span><?php echo esc_html($feature['title']); ?><span class="screen-reader-text"> <?php esc_html_e('(locked, part of Navarro Booking Pro)', 'navarro-booking-for-woocommerce'); ?></span></summary>
+            <div class="calentra-lock__panel">
+                <p><?php echo esc_html($feature['blurb']); ?></p>
+                <p><?php esc_html_e('Part of Navarro Booking Pro.', 'navarro-booking-for-woocommerce'); ?>
+                    <?php self::learn_more(); ?></p>
+            </div>
+        </details>
+        <?php
+    }
+
+    /**
+     * The one row at the foot of the settings page. Plain text, not a banner or a box.
+     */
+    public static function settings_footer() {
+        if (!self::locked()) {
+            return;
+        }
+        ?>
+        <p class="calentra-upgrade-foot description" id="<?php echo esc_attr(self::ANCHOR); ?>">
+            <?php esc_html_e('Navarro Booking Pro adds the features marked with a lock: resources, so bookings can be made against staff, rooms or equipment.', 'navarro-booking-for-woocommerce'); ?>
+            <?php self::learn_more(); ?>
+        </p>
+        <?php
+    }
+
+    /** A "learn more" link, or nothing when there is no URL. */
+    private static function learn_more() {
+        $url = self::url();
+        if ($url === '') {
+            return;
+        }
+        if (self::is_internal($url)) {
+            printf(
+                '<a href="%s">%s</a>',
+                esc_url($url),
+                esc_html__('Learn more', 'navarro-booking-for-woocommerce')
+            );
+            return;
+        }
+        printf(
+            '<a href="%s" target="_blank" rel="noopener noreferrer">%s<span class="screen-reader-text"> %s</span></a>',
+            esc_url($url),
+            esc_html__('Learn more', 'navarro-booking-for-woocommerce'),
+            esc_html__('(opens in a new tab)', 'navarro-booking-for-woocommerce')
+        );
+    }
+
+    /**
+     * "Settings", and "Upgrade" while any feature is locked, on the Plugins screen.
+     * Upgrade goes to the URL if there is one, else to the explanation on the settings
+     * page, so it is never a dead link.
+     */
+    public function action_links($links) {
+        if (!current_user_can('manage_woocommerce')) {
+            return $links;
+        }
+        $settings = admin_url('admin.php?page=calentra-settings');
+        $own = ['<a href="' . esc_url($settings) . '">' . esc_html__('Settings', 'navarro-booking-for-woocommerce') . '</a>'];
+        if (self::locked()) {
+            $url   = self::url();
+            $label = esc_html__('Upgrade', 'navarro-booking-for-woocommerce');
+            if ($url === '') {
+                $own[] = '<a href="' . esc_url($settings . '#' . self::ANCHOR) . '">' . $label . '</a>';
+            } elseif (self::is_internal($url)) {
+                $own[] = '<a href="' . esc_url($url) . '">' . $label . '</a>';
+            } else {
+                $own[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . $label . '</a>';
+            }
+        }
+        return array_merge($own, $links);
+    }
+
+    /** The lock styles, on the screens that show locks. */
+    public function enqueue() {
+        $screen = get_current_screen();
+        if (!$screen || !in_array($screen->id, ['product', 'woocommerce_page_calentra-settings', 'woocommerce_page_calentra-bookings'], true)) {
+            return;
+        }
+        wp_enqueue_style('calentra-upgrade', CALENTRA_PLUGIN_URL . 'assets/css/admin-upgrade.css', ['dashicons'], calentra_asset_version('assets/css/admin-upgrade.css'));
+    }
+}
Index: /navarro-booking-for-woocommerce/trunk/languages/navarro-booking-for-woocommerce.pot
===================================================================
--- /navarro-booking-for-woocommerce/trunk/languages/navarro-booking-for-woocommerce.pot	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/languages/navarro-booking-for-woocommerce.pot	(revision 3750436)
@@ -0,0 +1,663 @@
+# Copyright (C) 2026 Dominick Navarro
+# This file is distributed under the GPLv2 or later.
+msgid ""
+msgstr ""
+"Project-Id-Version: Navarro Booking for WooCommerce 2.5.0\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/navarro-booking-for-woocommerce\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"POT-Creation-Date: 2026-10-14T22:32:37+00:00\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"X-Generator: WP-CLI 2.12.0\n"
+"X-Domain: navarro-booking-for-woocommerce\n"
+
+#. Plugin Name of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "Navarro Booking for WooCommerce"
+msgstr ""
+
+#. Plugin URI of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "https://dominicknavarro.com/navarro-booking-pro"
+msgstr ""
+
+#. Description of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "Bookings for WooCommerce: time slots, multi-day rentals and capacity. Seats are never oversold."
+msgstr ""
+
+#. Author of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "Dominick Navarro"
+msgstr ""
+
+#. Author URI of the plugin
+#: navarro-booking-for-woocommerce.php
+msgid "https://dominicknavarro.com"
+msgstr ""
+
+#: includes/class-calentra-admin.php:59
+msgid "Navarro Booking Options"
+msgstr ""
+
+#: includes/class-calentra-admin.php:78
+msgid "Enable Booking"
+msgstr ""
+
+#: includes/class-calentra-admin.php:82
+msgid "Hide Quantity Field"
+msgstr ""
+
+#: includes/class-calentra-admin.php:84
+msgid "Hide the quantity selector on the product page for this booking."
+msgstr ""
+
+#: includes/class-calentra-admin.php:88
+msgid "Booking Type"
+msgstr ""
+
+#: includes/class-calentra-admin.php:90
+msgid "Timeslot"
+msgstr ""
+
+#: includes/class-calentra-admin.php:91
+msgid "Multi-Day (priced per night)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:93
+msgid "Time slots within opening hours, or a range of nights from a start date to an end date."
+msgstr ""
+
+#: includes/class-calentra-admin.php:97
+msgid "Start Time (e.g. 09:00)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:99
+msgid "End Time (e.g. 17:00)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:102
+msgid "Booking Duration (minutes)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:105
+#: includes/class-calentra-admin.php:487
+msgid "Bookable up to (days ahead)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:108
+msgid "Leave empty to use the global setting."
+msgstr ""
+
+#: includes/class-calentra-admin.php:109
+msgid "Blocked Dates (YYYY-MM-DD)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:111
+msgid "Blocked Time Slots (YYYY-MM-DD HH:MM)"
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-admin.php:115
+#, php-format
+msgid "Times are in the site timezone (%s)."
+msgstr ""
+
+#. translators: %s: the invalid value
+#: includes/class-calentra-admin.php:153
+#: includes/class-calentra-admin.php:427
+#, php-format
+msgid "\"%s\" is not a valid time (use HH:MM)."
+msgstr ""
+
+#: includes/class-calentra-admin.php:159
+msgid "The end time must be after the start time; no slots will be offered until this is fixed."
+msgstr ""
+
+#. translators: %s: list of rejected entries
+#. translators: %s: rejected entries
+#: includes/class-calentra-admin.php:173
+#: includes/class-calentra-admin.php:389
+#, php-format
+msgid "These blocked dates were not understood and were ignored: %s. Use YYYY-MM-DD."
+msgstr ""
+
+#. translators: %s: list of rejected entries
+#. translators: %s: rejected entries
+#: includes/class-calentra-admin.php:177
+#: includes/class-calentra-admin.php:400
+#, php-format
+msgid "These blocked time slots were not understood and were ignored: %s. Use YYYY-MM-DD HH:MM."
+msgstr ""
+
+#: includes/class-calentra-admin.php:193
+#: includes/class-calentra-admin.php:439
+msgid "Navarro Booking"
+msgstr ""
+
+#: includes/class-calentra-admin.php:218
+#: includes/class-calentra-bookings-list.php:71
+msgid "Booking"
+msgstr ""
+
+#: includes/class-calentra-admin.php:225
+#: includes/class-calentra-display.php:30
+#: includes/class-calentra-frontend.php:88
+msgid "Booking Date"
+msgstr ""
+
+#: includes/class-calentra-admin.php:234
+#: includes/class-calentra-display.php:32
+#: includes/class-calentra-frontend.php:98
+msgid "End Date"
+msgstr ""
+
+#. translators: %s: timezone
+#: includes/class-calentra-admin.php:244
+#, php-format
+msgid "Booking Time (%s)"
+msgstr ""
+
+#. translators: 1: product name, 2: error
+#: includes/class-calentra-admin.php:328
+#, php-format
+msgid "Navarro Booking: booking for %1$s was not changed. %2$s"
+msgstr ""
+
+#. translators: 1: product name, 2: new date and time
+#: includes/class-calentra-admin.php:338
+#, php-format
+msgid "Navarro Booking: booking for %1$s moved to %2$s."
+msgstr ""
+
+#: includes/class-calentra-admin.php:350
+#: includes/class-calentra-admin.php:351
+#: includes/class-calentra-bookings-list.php:295
+msgid "Bookings"
+msgstr ""
+
+#: includes/class-calentra-admin.php:362
+#: includes/class-calentra-admin.php:363
+msgid "Navarro Booking Settings"
+msgstr ""
+
+#: includes/class-calentra-admin.php:441
+#: includes/class-calentra-upgrade.php:210
+msgid "Settings"
+msgstr ""
+
+#: includes/class-calentra-admin.php:442
+msgid "Timezone diagnostics"
+msgstr ""
+
+#: includes/class-calentra-admin.php:464
+msgid "Global Start Time"
+msgstr ""
+
+#: includes/class-calentra-admin.php:468
+msgid "Global End Time"
+msgstr ""
+
+#: includes/class-calentra-admin.php:472
+msgid "Blocked Dates"
+msgstr ""
+
+#: includes/class-calentra-admin.php:474
+msgid "Comma-separated YYYY-MM-DD"
+msgstr ""
+
+#: includes/class-calentra-admin.php:477
+msgid "Blocked Time Slots"
+msgstr ""
+
+#: includes/class-calentra-admin.php:479
+msgid "Comma-separated YYYY-MM-DD HH:MM"
+msgstr ""
+
+#: includes/class-calentra-admin.php:482
+msgid "Cart hold (minutes)"
+msgstr ""
+
+#: includes/class-calentra-admin.php:484
+msgid "How long a slot stays reserved for a customer after they add it to their cart. The hold is refreshed while they are active in the cart or checkout. If it lapses and someone else books the slot, checkout is refused before payment."
+msgstr ""
+
+#: includes/class-calentra-admin.php:489
+msgid "How far ahead customers can book. Products can set their own limit."
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-admin.php:494
+#, php-format
+msgid "All times are in the site timezone (%s), set under Settings → General."
+msgstr ""
+
+#: includes/class-calentra-admin.php:509
+msgid "You do not have permission to do that."
+msgstr ""
+
+#: includes/class-calentra-availability.php:55
+#: includes/class-calentra-availability.php:358
+msgid "This product cannot be booked."
+msgstr ""
+
+#: includes/class-calentra-availability.php:60
+msgid "Invalid date range."
+msgstr ""
+
+#: includes/class-calentra-availability.php:67
+msgid "Date range too long."
+msgstr ""
+
+#: includes/class-calentra-availability.php:361
+msgid "Please choose a valid booking date."
+msgstr ""
+
+#: includes/class-calentra-availability.php:367
+msgid "Please choose an end date after the start date."
+msgstr ""
+
+#. translators: %s: a date
+#: includes/class-calentra-availability.php:378
+#, php-format
+msgid "Sorry, %s is not available for the dates you chose. Please pick different dates."
+msgstr ""
+
+#: includes/class-calentra-availability.php:396
+msgid "Please choose a time slot."
+msgstr ""
+
+#: includes/class-calentra-availability.php:410
+msgid "Sorry, that time slot is not available. Please choose another."
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:72
+#: includes/class-calentra-diagnostics.php:120
+msgid "Product"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:73
+msgid "Resource"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:74
+msgid "Start"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:75
+msgid "End"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:76
+msgid "Seats"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:77
+#: includes/class-calentra-diagnostics.php:122
+msgid "Status"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:78
+#: includes/class-calentra-diagnostics.php:121
+msgid "Order"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:95
+msgid "No bookings match these filters."
+msgstr ""
+
+#. translators: %d: product ID
+#: includes/class-calentra-bookings-list.php:167
+#, php-format
+msgid "Product #%d (deleted)"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:182
+msgid "Filter by product"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:184
+msgid "All products"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:190
+msgid "Filter by status"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:197
+msgid "From"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:199
+msgid "to"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:202
+msgid "Filter"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:209
+msgid "Active (confirmed and live holds)"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:210
+msgid "Confirmed"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:211
+msgid "In a cart (hold)"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:212
+msgid "Cancelled"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:213
+msgid "Expired hold"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:214
+msgid "All statuses"
+msgstr ""
+
+#: includes/class-calentra-bookings-list.php:259
+msgid "Hold (lapsed)"
+msgstr ""
+
+#. translators: %s: time the hold expires
+#: includes/class-calentra-bookings-list.php:263
+#, php-format
+msgid "In a cart until %s"
+msgstr ""
+
+#. translators: %d: order number
+#: includes/class-calentra-bookings-list.php:276
+#, php-format
+msgid "#%d (deleted)"
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-bookings-list.php:299
+#, php-format
+msgid "Times are shown in the site timezone (%s)."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:475
+msgid "Choose a resource for this booking."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:500
+msgid "Sorry, someone else has just booked that slot. Please choose another."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:511
+msgid "The booking could not be saved. Please try again."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:541
+#: includes/class-calentra-bookings.php:559
+msgid "Your reservation has expired."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:548
+msgid "Sorry, there are not enough places left in that slot."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:610
+msgid "Your reservation could not be found. Please choose your booking again."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:658
+msgid "Sorry, your reservation expired and the slot has since been booked by someone else. You have not been charged. Please choose another time."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:695
+msgid "Booking not found."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:716
+msgid "That slot is already fully booked."
+msgstr ""
+
+#: includes/class-calentra-bookings.php:751
+msgid "This booking is too long to be made on a resource. Please choose a shorter booking."
+msgstr ""
+
+#. translators: 1: product name, 2: error message
+#: includes/class-calentra-cart.php:248
+#, php-format
+msgid "%1$s: your reservation expired and the slot is no longer available. Please remove it from your cart and choose another time. (%2$s)"
+msgstr ""
+
+#. translators: %s: product name
+#: includes/class-calentra-cart.php:366
+#, php-format
+msgid "Navarro Booking: the booking for %s could not be confirmed because the slot was taken. No payment was attempted."
+msgstr ""
+
+#. translators: %s: product name
+#: includes/class-calentra-cart.php:421
+#, php-format
+msgid "Navarro Booking: the booking for %s could not be restored because the slot has since been booked by someone else."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:38
+msgid "Your browser is in a different timezone from the site. Customers in your timezone will see both times next to each slot."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:56
+msgid "Inventory counters rebuilt from bookings."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:59
+msgid "Clocks"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:62
+msgid "Site timezone (Settings → General)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:66
+msgid "This is a fixed UTC offset, not a city. Fixed offsets do not follow daylight saving time, so slot times will be an hour off for part of the year. Choose a city such as \"America/New_York\" instead."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:69
+msgid "Your browser timezone"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:72
+msgid "Server clock (UTC)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:74
+msgid "Database clock (UTC)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:75
+msgid "session time_zone"
+msgstr ""
+
+#. translators: %d: seconds
+#: includes/class-calentra-diagnostics.php:79
+#, php-format
+msgid "The database and web server clocks differ by %d seconds. Hold expiry may be inaccurate."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:82
+msgid "How Navarro Booking stores times"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:83
+msgid "Every booking is stored as a UTC instant together with the site timezone at the time it was made. Customers see slots in the site timezone, with their own local time alongside when it differs."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:87
+msgid "Upcoming daylight-saving changes"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:92
+msgid "None in the next 12 months for this timezone."
+msgstr ""
+
+#. translators: 1: abbreviation, 2: UTC offset in hours
+#: includes/class-calentra-diagnostics.php:98
+#, php-format
+msgid "clocks change to %1$s (UTC%2$+.1f h)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:104
+msgid "Upcoming bookings"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:113
+msgid "No upcoming bookings."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:123
+msgid "Stored (UTC)"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:124
+msgid "Site time"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:125
+msgid "Your time"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:126
+msgid "Notes"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:152
+msgid "Inventory"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:153
+msgid "Seat counters are kept alongside bookings so that two customers can never claim the last seat at once. If you have edited the database by hand, rebuild them from the bookings."
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:157
+msgid "Rebuild inventory counters"
+msgstr ""
+
+#: includes/class-calentra-diagnostics.php:175
+msgid "A daylight-saving change falls on this booking's date: double-check the time with the customer."
+msgstr ""
+
+#. translators: %+.1f: hours
+#: includes/class-calentra-diagnostics.php:183
+#, php-format
+msgid "Clocks change before this booking (offset %+.1f h); its UTC time differs from today's by that much. This is expected."
+msgstr ""
+
+#. translators: 1: old timezone, 2: new timezone
+#: includes/class-calentra-diagnostics.php:190
+#, php-format
+msgid "Booked when the site timezone was %1$s; it is now %2$s. The booked instant is unchanged."
+msgstr ""
+
+#: includes/class-calentra-display.php:31
+#: includes/class-calentra-frontend.php:90
+msgid "Time Slot"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:50
+msgid "Loading…"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:51
+msgid "Select a time slot"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:52
+msgid "No available slots"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:53
+msgid "your time"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:54
+msgid "Please select a booking date and time."
+msgstr ""
+
+#: includes/class-calentra-frontend.php:55
+msgid "Please select both start and end dates."
+msgstr ""
+
+#: includes/class-calentra-frontend.php:56
+msgid "Availability could not be loaded. Please refresh the page."
+msgstr ""
+
+#. translators: %s: timezone name
+#: includes/class-calentra-frontend.php:58
+#, php-format
+msgid "Times shown in %s"
+msgstr ""
+
+#. translators: %d: number of nights
+#: includes/class-calentra-frontend.php:60
+#, php-format
+msgid "%d night"
+msgid_plural "%d nights"
+msgstr[0] ""
+msgstr[1] ""
+
+#: includes/class-calentra-frontend.php:72
+msgid "Book Now"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:92
+msgid "Select a date first"
+msgstr ""
+
+#: includes/class-calentra-frontend.php:96
+msgid "Start Date"
+msgstr ""
+
+#. translators: 1: site name, 2: product name
+#: includes/class-calentra-ics.php:90
+#, php-format
+msgid "Booking from %1$s for %2$s"
+msgstr ""
+
+#: includes/class-calentra-rest.php:43
+msgid "Product not found."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:60
+msgid "Resources"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:61
+msgid "Book staff, rooms or equipment, chosen by the customer or assigned to whichever is free."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:153
+msgid "(locked, part of Navarro Booking Pro)"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:156
+msgid "Part of Navarro Booking Pro."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:172
+msgid "Navarro Booking Pro adds the features marked with a lock: resources, so bookings can be made against staff, rooms or equipment."
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:188
+#: includes/class-calentra-upgrade.php:195
+msgid "Learn more"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:196
+msgid "(opens in a new tab)"
+msgstr ""
+
+#: includes/class-calentra-upgrade.php:213
+msgid "Upgrade"
+msgstr ""
Index: /navarro-booking-for-woocommerce/trunk/navarro-booking-for-woocommerce.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/navarro-booking-for-woocommerce.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/navarro-booking-for-woocommerce.php	(revision 3750436)
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Plugin Name:       Navarro Booking for WooCommerce
+ * Plugin URI:        https://dominicknavarro.com/navarro-booking-pro
+ * Description:       Bookings for WooCommerce: time slots, multi-day rentals and capacity. Seats are never oversold.
+ * Version:           2.5.0
+ * Requires at least: 6.5
+ * Requires PHP:      7.4
+ * Requires Plugins:  woocommerce
+ * Author:            Dominick Navarro
+ * Author URI:        https://dominicknavarro.com
+ * License:           GPLv2 or later
+ * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
+ * Text Domain:       navarro-booking-for-woocommerce
+ * Domain Path:       /languages
+ * WC requires at least: 8.0
+ *
+ * Navarro Booking for WooCommerce
+ * Copyright (C) 2025-2026 Dominick Navarro
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 2 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ */
+
+defined('ABSPATH') || exit;
+
+define('CALENTRA_VERSION', '2.5.0');
+define('CALENTRA_PLUGIN_FILE', __FILE__);
+define('CALENTRA_PLUGIN_PATH', plugin_dir_path(__FILE__));
+define('CALENTRA_PLUGIN_URL', plugin_dir_url(__FILE__));
+
+/**
+ * Version string for a plugin asset, from its modification time. Scripts used to be
+ * enqueued with no version, so browsers and Cloudflare kept serving stale copies
+ * (up to 30 days) after the files changed.
+ */
+function calentra_asset_version($relative_path) {
+    $file = CALENTRA_PLUGIN_PATH . $relative_path;
+    return file_exists($file) ? (string) filemtime($file) : CALENTRA_VERSION;
+}
+
+require_once CALENTRA_PLUGIN_PATH . 'includes/class-calentra-install.php';
+require_once CALENTRA_PLUGIN_PATH . 'includes/class-calentra-loader.php';
+
+register_activation_hook(__FILE__, ['Calentra_Install', 'install']);
+register_deactivation_hook(__FILE__, function () {
+    wp_clear_scheduled_hook('calentra_sweep');
+});
+
+// Bookings live in a custom table, so the order storage engine doesn't matter to us.
+add_action('before_woocommerce_init', function () {
+    if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('cart_checkout_blocks', __FILE__, true);
+    }
+});
+
+add_action('plugins_loaded', function () {
+    if (!class_exists('WooCommerce')) {
+        return;
+    }
+    Calentra_Loader::includes();
+    (new Calentra_Loader())->run();
+});
+
+// On init rather than plugins_loaded: the migration reads orders, and WooCommerce's
+// order data stores are not ready until partway through init.
+add_action('init', function () {
+    if (class_exists('WooCommerce')) {
+        Calentra_Install::maybe_upgrade();
+    }
+}, 20);
Index: /navarro-booking-for-woocommerce/trunk/readme.txt
===================================================================
--- /navarro-booking-for-woocommerce/trunk/readme.txt	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/readme.txt	(revision 3750436)
@@ -0,0 +1,175 @@
+=== Navarro Booking for WooCommerce ===
+Contributors: shirofuji00
+Tags: booking, appointments, woocommerce, calendar, reservations
+Requires at least: 6.5
+Tested up to: 7.1
+Requires PHP: 7.4
+Requires Plugins: woocommerce
+Stable tag: 2.5.0
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+Time-slot and multi-day bookings for WooCommerce: real capacity, a cache-safe calendar and timezone-correct times. Seats are never oversold.
+
+== Description ==
+
+Navarro Booking turns any WooCommerce product into something customers can book: a class, an appointment, a room for a night. Customers pick a date and a time slot (or a start and end date) on the product page, and the booking goes through the normal WooCommerce cart and checkout.
+
+It is built around three problems booking plugins are known for.
+
+**Never oversold.** A slot is reserved the moment a customer adds it to the cart, with a single atomic database operation, so two customers can never both take the last place. The reservation is checked again when the order is placed; if it has lapsed and someone else has taken the slot, the order is refused before any payment is taken. The plugin ships with a public concurrency test suite that races many customers for one slot and checks that exactly the right number succeed.
+
+**Works with page caching.** Availability is loaded from a public, read-only REST endpoint with no nonce, so a product page served from WP Rocket, LiteSpeed Cache or Cloudflare keeps working. There is nothing to exclude from your cache.
+
+**Times are right.** Bookings are stored as exact UTC instants together with your site timezone. Customers see each slot in your timezone, labelled, with their own local time alongside when it differs. A diagnostics screen shows your site, server, database and browser clocks, upcoming daylight-saving changes, and how each upcoming booking is stored.
+
+= Features =
+
+* Time-slot bookings within opening hours, or multi-day bookings priced per night
+* Per-product settings layered over global defaults
+* Block whole dates or individual time slots, per product or site-wide
+* Cart reservations that don't depend on WP-Cron
+* Works with the block cart and checkout and the classic shortcodes
+* `.ics` calendar invite attached to order emails
+* Edit a booking's date and time from the order screen (capacity is still enforced)
+* Bookings released automatically when orders are cancelled, failed, refunded or deleted
+* Timezone diagnostics screen
+* Compatible with WooCommerce High-Performance Order Storage
+* Documented actions and filters for developers (see `docs/extending.md`)
+* Translation-ready
+
+= Navarro Booking Pro =
+
+Navarro Booking Pro is a separate, paid plugin that adds resources: book staff, rooms or equipment, chosen by the customer or assigned to whichever is free. A resource shared by several products is never booked twice for the same time, with the same atomic guarantee as the rest of Navarro Booking.
+
+This plugin is complete without it. Nothing here is time-limited, and nothing stops working if you never buy Pro. Where resources would appear in a product's booking options, a lock explains them when clicked; there are no notices or banners.
+
+[Navarro Booking Pro](https://dominicknavarro.com/navarro-booking-pro)
+
+== Installation ==
+
+1. Install and activate WooCommerce.
+2. Upload the plugin to `/wp-content/plugins/navarro-booking-for-woocommerce/`, or install it from the Plugins screen, and activate it.
+3. Under **Settings → General**, make sure your timezone is a city (for example "America/New_York"), not a fixed UTC offset.
+4. Visit **WooCommerce → Navarro Booking Settings** for global opening hours and blocked dates.
+5. Edit a product and tick **Enable Booking** in the **Navarro Booking Options** box.
+
+== Frequently Asked Questions ==
+
+= Do I need to exclude product pages from my page cache? =
+
+No. The booking calendar loads availability from a public REST endpoint that doesn't use a nonce, so cached pages keep working.
+
+= How long is a slot held while a customer is in checkout? =
+
+60 minutes by default, changeable under WooCommerce → Navarro Booking Settings. The hold is refreshed while the customer is active in the cart or checkout. If it lapses, the slot is taken again at checkout if it is still free; if someone else has booked it, checkout is refused before payment.
+
+= What happens to multi-day end dates? =
+
+The end date is the departure date. A booking from the 3rd to the 6th covers the nights of the 3rd, 4th and 5th and is priced as three nights; someone else can start a booking on the 6th.
+
+= Can more than one customer book the same slot? =
+
+Each slot holds one booking by default. Developers can raise the number of places with the `calentra_slot_capacity` filter; everything else, including the concurrency guarantees, works with any capacity.
+
+= Does it sync with Google Calendar? =
+
+Every order email carries an `.ics` invite that any calendar app can import. There is no two-way sync.
+
+= I'm upgrading from 1.x. What happens to my bookings? =
+
+They are migrated on the first page load after the update. Version 1.1.0 had a bug that stopped bookings reaching its bookings table; the migration recovers those from your orders, so slots already sold stay blocked. The old table is kept as a backup until you delete the plugin.
+
+= Is there a paid version? =
+
+Yes, [Navarro Booking Pro](https://dominicknavarro.com/navarro-booking-pro), a separate plugin that adds bookable staff, rooms and equipment. Everything described above is in this free plugin and stays free.
+
+== Screenshots ==
+
+1. Choosing a time slot on the product page, with the timezone shown.
+2. Booking options on a product.
+3. Global settings.
+4. Timezone diagnostics.
+
+== Changelog ==
+
+= 2.5.0 =
+* Change: renamed from Calentra Booking for WooCommerce to Navarro Booking for WooCommerce. Settings, bookings and developer hooks are unchanged.
+
+= 2.4.1 =
+* Change: the bundled Freemius SDK is removed. This plugin contains no licensing, update or telemetry code. "Learn more" and "Upgrade" now link to the Navarro Booking Pro page.
+* Dev: the timezone diagnostics screen now loads its script and styles as enqueued assets rather than printing them inline.
+* Dev: compiled translations are no longer bundled. Translations are served through translate.wordpress.org instead.
+
+= 2.4.0 =
+* Fix: a resource offered by several products (a teacher, a room) is now counted once across all of them. Before, each product counted it separately, so two products could both sell the last place on it for the same time. Bookings that overlap are caught even when the products' slots start at different times.
+* Fix: "Rebuild inventory" on the diagnostics screen no longer undercounts a slot that has a lapsed cart hold.
+* Dev: `calentra_slot_capacity` is also asked with product 0 for a resource's whole capacity. A booking on a resource may span at most about 33 hours (`calentra_too_long`).
+* Fix: the Booking Options panel now opens in the main column instead of the narrow side column, where its times, blocked dates and resource rows were squeezed. If you have already dragged it somewhere, WordPress keeps your own arrangement.
+* The update counts existing bookings on resources once, on the first request after updating. For a perfectly clean update, put the shop in maintenance mode or run the update with WP-CLI before customers reach it; otherwise use "Rebuild inventory" afterwards.
+
+= 2.3.0 =
+* New: Navarro Booking Pro's resources are shown where they would be in the product's booking options, with a lock. The lock explains itself only when clicked and is not shown once Pro is active. No notices, no dashboard widget, and nothing on activation.
+* New: "Settings" and "Upgrade" links on the Plugins screen.
+* Dev: `calentra_feature_unlocked` says whether a feature is available, and `calentra_upgrade_url` sets where "learn more" goes. It is empty by default, and then no link is shown.
+
+= 2.2.0 =
+* Dev: `calentra_claim_resources` lets an extension pool resources. Core tries each candidate with the same atomic claim in turn, and the first with room wins, so "any free member of staff" or "any free room" never needs a read-then-write.
+* Dev: the cart remembers which resource a hold was actually claimed on, so the customer's own hold is found again instead of a second seat being claimed.
+* Dev: the date picker sends `resource_id` when the product form has a `calentra_resource_id` field, and reloads availability when it changes.
+* Dev: `calentra_ics_summary` filters the title of the `.ics` invite.
+* Dev: when `calentra_claim_resources` offers no resource at all, the claim fails with "Choose a resource for this booking" instead of claiming the slot was just taken, and `calentra_claim_failed` does not fire.
+* No change in behaviour without an extension using these.
+
+= 2.1.0 =
+* New: WooCommerce → Bookings lists bookings with their product, resource, start and end in the site timezone, seats, status and order. Filter by product, status and date range.
+* New: how far ahead customers can book is now a setting, with a global default (365 days) and an optional per-product limit. It was fixed at 365 days.
+* New: the availability endpoint accepts an optional `resource_id` and returns it in every response, so extensions that book against resources can ask about one resource at a time. Responses without it are unchanged apart from the added `"resource_id": 0`.
+* Dev: `calentra_resource_label` filter names resources in the bookings list.
+
+= 2.0.0 =
+* New: slots are reserved atomically when added to the cart, and confirmed atomically at checkout. A slot can no longer sell more seats than it has, including under heavy concurrent traffic.
+* New: the date picker loads availability from a public REST endpoint, so it works on cached pages.
+* New: bookings are stored as UTC instants with the site timezone; slots are labelled with their timezone and the customer's local time.
+* New: timezone diagnostics screen.
+* New: cart hold duration setting.
+* New: `.ics` invites built from the stored UTC time, with a stable UID; temporary files are removed after sending.
+* New: actions and filters for extensions, documented in `docs/extending.md`.
+* Fix: requested dates and times were not checked when added to the cart, so blocked, past or out-of-hours slots could be bought.
+* Fix: bookings from 1.1.0 were never saved to the bookings table, and `.ics` invites were never attached. Existing orders are migrated.
+* Fix: global blocked dates and blocked time slots could not be saved.
+* Fix: booking options are saved only for products, with nonce and capability checks. 1.x wrote empty booking settings onto every page and post you saved; these are removed.
+* Fix: invalid blocked dates are reported instead of silently ignored.
+* Fix: multi-day availability no longer loads every order on every request.
+* Fix: "Hide Quantity Field" now applies on the shop front end.
+* Fix: multi-day price display no longer assumes the Philippine peso.
+
+= 1.1.0 =
+* Multi-day bookings.
+
+= 1.0.0 =
+* Initial release.
+
+== Upgrade Notice ==
+
+= 2.5.0 =
+New name, same plugin. No database changes.
+
+= 2.4.0 =
+Fixes double selling of a resource shared by several products. Adds a database index and counts existing resource bookings on the first request after updating.
+
+= 2.3.0 =
+Shows Pro's resources, locked, in the product's booking options, explained only when clicked. No database changes.
+
+= 2.2.0 =
+Developer hooks for booking against pools of resources. No change for existing shops, and no database changes.
+
+= 2.1.0 =
+Adds a Bookings screen under WooCommerce and a setting for how far ahead customers can book. No database changes.
+
+= 2.0.0 =
+Prevents oversold slots and fixes bookings not being recorded in 1.1.0. Back up your database before updating; existing bookings are migrated automatically.
+
+== Third-Party Libraries ==
+
+* Flatpickr 4 (MIT License) — https://flatpickr.js.org/
Index: /navarro-booking-for-woocommerce/trunk/uninstall.php
===================================================================
--- /navarro-booking-for-woocommerce/trunk/uninstall.php	(revision 3750436)
+++ /navarro-booking-for-woocommerce/trunk/uninstall.php	(revision 3750436)
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Removes Navarro Booking's settings when the plugin is deleted.
+ *
+ * Booking records are kept: they are the history of orders customers paid for, and
+ * deleting a plugin should not silently erase them. Define CALENTRA_REMOVE_ALL_DATA as
+ * true in wp-config.php before deleting the plugin to drop the tables as well.
+ */
+if (!defined('WP_UNINSTALL_PLUGIN')) {
+    exit;
+}
+
+foreach ([
+    'calentra_global_start',
+    'calentra_global_end',
+    'calentra_global_blocked_dates',
+    'calentra_global_blocked_slots',
+    'calentra_hold_minutes',
+    'calentra_max_days_ahead',
+    'calentra_global_cache_version',
+    'calentra_upgrade_lock',
+    'calentra_upgrade_url',
+] as $calentra_option) {
+    delete_option($calentra_option);
+}
+wp_clear_scheduled_hook('calentra_sweep');
+
+global $wpdb;
+// The v1.1.0 table kept as a backup by the 2.0 migration.
+$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}calentra_bookings_v1"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- dropping Navarro Booking's own tables on uninstall.
+$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}calentra_slot_inventory"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- rebuilt from bookings on reinstall.
+
+if (defined('CALENTRA_REMOVE_ALL_DATA') && CALENTRA_REMOVE_ALL_DATA) {
+    $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}calentra_bookings"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- dropping Navarro Booking's own tables on uninstall.
+    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- bulk meta removal on uninstall; no core API deletes by key pattern.
+    $wpdb->query("DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_calentra\\_%'");
+    delete_option('calentra_db_version');
+    delete_option('calentra_v1_migrated');
+}
