commit 8fc77212cb450c198d71aff4f3fe714eab1498d5 Author: AI Agent Date: Fri Oct 2 01:23:25 2026 +0200 Startpunkt 02.10.2026: aktueller Stand des Trainingsplans Historie bewusst neu aufgesetzt, ein einziger Commit als neuer Ausgangspunkt. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0523177 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# data/ = komplette Nutzerdaten (state.db), nie ins Repo +data/ +*.db +*.db-wal +*.db-shm +*.tmp + +# Logs +*.log +server.log + +# Betriebssystem +.DS_Store + +# Python +__pycache__/ +*.pyc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..599a234 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Trainingsplan - Container-Image (slim Python, Standardbibliothek nur). +FROM python:3.13-slim + +WORKDIR /app + +# App-Dateien (kein Build-Schritt, kein Framework) +COPY server.py index.html styles.css app.js config.json ./ + +# State-Verzeichnis als Volume (Daten ueberleben Container-Neustart) +VOLUME ["/app/data"] +ENV TRAINING_STATE_DIR=/app/data +ENV PORT=8080 + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD python3 -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/api/meta',timeout=2)" || exit 1 + +CMD ["python3", "server.py"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + 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 +them 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey 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; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + 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. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +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. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + 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 +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bf3ad6 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# REKORD + +**Rekord & Record: dein Training, festgehalten.** + +Ein leichtgewichtiger, selbst gehosteter Trainings-Tracker fürs Gym, Laufen +und Radfahren. Läuft in jedem modernen Browser, speichert auf deiner Platte +und wertet deine Fitnessdaten nicht aus. Kein Abo, kein Konto bei einem +Anbieter, keine Datenkraken. + +## Warum REKORD? + +- **Kein Vendor Lock-in.** Alles läuft bei dir: Backend auf deinem Server, + Zustand in einer portablen SQLite-Datei. Export jederzeit als JSON oder + CSV, Import genauso. Zieh um oder hör auf - deine Daten gehören dir. +- **Leichtgewichtig.** Python-Standardbibliothek + SQLite im Backend, ein + HTML-, ein CSS-, ein JS-File im Frontend. Kein Framework, kein + Build-Schritt, keine Node-Installation. Läuft auf einem Mini-PC, einem + alten Laptop oder in einem Container. +- **Browserbasiert.** Keine App, kein Store, keine Installation. Aufschlagen, + loslegen - auf jedem Gerät mit Browser. +- **Cross-Device Sync.** Der Zustand liegt serverseitig. Öffne die App am + Handy, am Laptop oder am Desktop-Rechner - es ist derselbe Stand. Mehrere + Benutzer mit eigenen Konten und getrennten Daten sind eingebaut. +- **UX zuerst.** Sätze eintragen mit einer Box (Zahl + Einheit in einem + Feld), Set-Kette durch Abhaken, "Alles zurücksetzen", Verlauf inline + bearbeiten, Wochenübersicht, Statistik mit Differenz zur ersten Messung. + Der Tracker reibt sich nicht in den Weg zwischen dich und die Übung. +- **Dein Take aufs Aufzeichnen.** Die App zeichnet auf - Gewichte, Reps, + Cardio-Felder - und zeigt die Entwicklung über die Zeit. Sie verkauft + nichts, personalisiert nichts und schickt nichts nach Hause. +- **Open Source.** AGPL-3.0. Nachvollziehbar, erweiterbar, auditierbar. + +## Features im Überblick + +**Training erfassen** + +- Gym mit Push/Pull/Legs (frei konfigurierbar) plus Laufen und Radfahren +- Eingabe als eine Box: Zahl links, Einheit fest rechts (kg, reps, km, min, + min/km, bpm, m) +- Set-Abhaken als "Set n [ ]": der ganze Button ist klickbar, das Set hakt + eine Kette ab - ohne Lücken +- Reset-Satz und "Alles zurücksetzen" laden das letzte abgeschlossene + Training +- Vorbefüllung beim Start (optional): "Letztes Training vorbefüllen" oder + "Plan-Gewichte vorbefüllen" (startKg aus der Config, auch ohne Verlauf) +- Übungen per Drag & Drop sortieren, Übungen aus anderen Tagen spontan + hinzufügen +- Fragezeichen pro Übung: Erklärung plus die passende Maschine + +**Sessions & Verlauf** + +- "Session starten" / "Session abschließen", mehrere Sessions am Tag +- Keine Geister: erst abgeschlossene Sessions erscheinen im Verlauf +- Verlauf als Liste oder Wochenübersicht, inline bearbeitbar (Datum, Zeiten, + Abhak-Status, Werte) und löschbar +- Statistik: bestes Gewicht je Trainingstag mit Balken, Differenz zur ersten + Messung + +**Ansicht & Einstellungen** + +- Deutsch und Englisch (Server-Standard und pro Benutzer) +- Farbthema: Dunkel, System (folgt dem Gerät), Hell - plus OLED-Dark-Schalter +- Akzentfarbe aus zehn Farben (inklusive Weiß) +- Gewichtseinheit kg oder lb, Start-Ansicht wählbar +- Einstellungen serverseitig, gelten auf allen Geräten + +**Mehrbenutzer** + +- Eigene Konten mit Kennwort (gehasht), Daten strikt getrennt +- Der erste Benutzer ist Administrator und verwaltet Konten und Kennwörter +- Einmaliger Einrichtungs-Wizard beim ersten Start (Sprache, Modus, + Darstellung) + +**Daten** + +- Export/Import als JSON und CSV (Backup, Umzug, Tabellen-Auswertung) +- Workouts über eine kleine API abrufbar (`/api/workouts`) +- Demo-Daten (`demo-data.json`) zum direkten Ausprobieren + +## Schnellstart + +Python (nur Standardbibliothek, kein `pip install`): + +```bash +python3 server.py 8080 +``` + +Oder mit Docker: + +```bash +docker compose up -d +``` + +Danach im Browser: `http://:8080/` + +Beim ersten Start führt dich ein kurzer Wizard durch Sprache, Einzel- oder +Mehrbenutzermodus und Darstellung - danach ist die App einsatzbereit. + +## Den Übungsplan anpassen + +Alle Übungen, Sätze, Gewichte, Maschinen und Erklärungen stehen in +`config.json` - ohne Code-Eingriff. Das komplette Schema steht in der +[technischen Dokumentation](TECH.md). + +## Daten & Privatsphäre + +- Alles liegt in `data/state.db` (SQLite) - backupbar, überlebt Neustart und + Container-Rebuild +- Datenverzeichnis frei wählbar (`TRAINING_STATE_DIR`) +- Keine externen Dienste, keine Telemetrie, keine Analyse deiner Daten + +## Lizenz + +AGPL-3.0. Siehe `LICENSE`. \ No newline at end of file diff --git a/TECH.md b/TECH.md new file mode 100644 index 0000000..df46431 --- /dev/null +++ b/TECH.md @@ -0,0 +1,538 @@ +# REKORD - Technische Dokumentation + +Vollständige technische Doku für den selbstgehosteten Trainingsverlaufs-Tracker. +Funktionen und Bedienung: siehe [README.md](README.md). + +- [Überblick & Stack](#überblick--stack) +- [Projektstruktur](#projektstruktur) +- [Zentrale Architektur-Entscheidungen](#zentrale-architektur-entscheidungen) +- [Backend](#backend) + - [HTTP-API (Referenz)](#http-api-referenz) + - [Datenmodell (SQLite)](#datenmodell-sqlite) + - [Set-Reconcile (Zustands-Isolierung)](#set-reconcile-zustands-isolierung) + - [Einstellungen](#einstellungen) + - [Auto-Reset / Autofill](#auto-reset--autofill) + - [Export / Import](#export--import) +- [Frontend](#frontend) + - [Zustandsmodell & Kein-Re-Render-Prinzip](#zustandsmodell--kein-re-render-prinzip) + - [Autosave](#autosave) + - [Set-Kette & Abhak-Status](#set-kette--abhak-status) + - [Theme & Akzent](#theme--akzent) +- [config.json - Schema](#configjson---schema) +- [Deployment](#deployment) + - [Environment-Variablen](#environment-variablen) + - [Docker / CasaOS](#docker--casaos) +- [Tests](#tests) +- [Erweitern](#erweitern) + +--- + +## Überblick & Stack + +Ein Python-Backend aus der Standardbibliothek serviert statische Dateien und +eine JSON-API; ein JS-Frontend ohne Framework baut die UI. Persistenz: SQLite +in `data/state.db` (Pfad per `TRAINING_STATE_DIR`). + +``` +Browser (index.html, styles.css, app.js) + | JSON-API (http.server) + v +server.py -> SQLite (sessions, entries, settings) + | +config.json (fachlicher Plan, serverseitig normalisiert) +``` + +Kein Framework, kein Build-Schritt, keine externen Abhängigkeiten (nur +Python-Standardbibliothek). Portabel per `python3` oder Docker. + +## Projektstruktur + +``` +server.py Backend: API, SQLite, Config-Normalisierung, Export/Import +index.html Seitengerüst (Tabs, View-Container, Overlay, Toast) +styles.css Design-Tokens + Komponenten (Theme/Akzent via data-Attribute) +app.js Frontend: Zustandsmodell, Views, Autosave +config.json Fachlicher Plan (Übungen, Sätze, Maschinen, Erklärungen) +demo-data.json 5 Wochen Verlauf zum Ausprobieren (Import über Tab "Daten") +tests/ Test-Suiten (API + jsdom-UI, offener Modus bzw. Auth) +Dockerfile Container-Image (slim Python, nur Standardbibliothek) +docker-compose.yml CasaOS-tauglich, Daten als echter Ordner +data/ Zustand (SQLite), gitignored +``` + +## Zentrale Architektur-Entscheidungen + +1. **Konfiguration statt Code.** Der komplette Trainingsplan liegt in + `config.json`. Der Server normalisiert die Rohstruktur beim Start einmal; + das Frontend kennt nur die normalisierte Form. Ein Nicht-Programmierer + pflegt nur die JSON. +2. **Stabile Set-IDs + Übungs-Isolierung.** Jedes Set ist eine DB-Zeile + (`entries`) mit Auto-ID. Updates sind Upserts über + `(session_id, exercise, sort)`. Löschen passiert nur innerhalb derselben + Übung (`NOT IN` der gelieferten Sorts). Eine Änderung in Übung A berührt + Übung B strukturell nie. +3. **Kein Re-Render beim Tippen.** Die Heute-Ansicht wird einmal gebaut; + Tippen schreibt nur ins Set-State-Objekt und löst einen Debounce-Autosave + aus. Das DOM wird nie global neu gebaut - Fokus/Cursor bleiben intakt. +4. **Keine Geister-Einträge.** `sessions.status` ist `active` oder `done`. + Der Verlauf zeigt nur `done`. Gestartete Sessions überleben Reloads und + sind per Knopf übertragbar oder verwerfbar. +5. **Serverzeit ist maßgeblich.** Datum und Endzeit kommen vom Server + (`/api/meta`), damit nichts am Browser-TZ scheitert. +6. **Auto-Reset ist serverseitig und opt-in.** Nur wenn die Einstellung + gesetzt ist, füllt der Server beim Session-Start das letzte Workout ein. +7. **Mehrbenutzer mit offenem Einzelmodus.** Solange kein Benutzer existiert, + läuft die App ohne Anmeldung (user_id=0). Der erste registrierte Benutzer + übernimmt vorhandene Daten; danach ist für alle Daten ein Bearer-Token + nötig und alles ist strikt pro Benutzer getrennt. +8. **Design als Richtung, Nutzbarkeit zuerst.** 0px Radius, 2px-Borders, ein + Akzent, dunkler Grund. Zustaende (Hover/Fokus/Aktiv) ueberall. Keine + Verläufe, kein Glow, keine Emojis, keine Em-Dashes. + +--- + +## Backend + +### HTTP-API (Referenz) + +| Methode | Pfad | Beschreibung | +|---|---|---| +| GET | `/` | index.html | +| GET | `/styles.css` | Styles | +| GET | `/app.js` | Frontend-Code | +| GET | `/api/meta` | `{today, now, auth_required, users, language}` - Serverzeit + Auth-Status + Server-Sprache | +| GET | `/api/config` | normalisierte Konfiguration | +| GET | `/api/settings` | Einstellungen des Benutzers (Default + gespeicherte Werte) | +| PUT | `/api/settings` | Teil-Update, Body `{key: value}`, liefert vollständige Settings | +| GET | `/api/sessions` | Filter: `activity`, `status` (`active`\|`done`), `from`, `to` | +| POST | `/api/sessions` | Session starten, Body `{activity, day?, date?, start_time?}` | +| PATCH | `/api/sessions/` | Teil-Update: `{meta: {...}}` und/oder `{sets: [...]}` | +| POST | `/api/sessions//autofill` | "Alles zurücksetzen": Sätze auf letztes Workout setzen | +| DELETE | `/api/sessions/` | Session samt Sätzen löschen | +| GET | `/api/workouts` | abgeschlossene Workouts, Übungen gruppiert (Filter: `activity`, `from`, `to`, `limit`) | +| GET | `/api/reset-values?exercise=&set=` | letzte abgeschlossene Messung eines Sets | +| GET | `/api/stats?exercise=` | Steigerung je Termin (bestes Gewicht + Delta) | +| GET | `/api/export?format=json\|csv` | kompletter Stand des Benutzers | +| POST | `/api/import` | `{format, data}` (siehe Export/Import) | +| POST | `/api/register` | erster Benutzer (nur solange keine existieren) | +| POST | `/api/login` | `{username, password}` -> `{token, username, id}` | +| POST | `/api/logout` | Token invalidieren | +| POST | `/api/password` | eigenes Passwort ändern `{current_password, new_password}` (Auth) | +| GET | `/api/me` | `{username, is_owner}` (401 ohne gültigen Token) | +| GET | `/api/users` | Benutzerliste (nur Owner) | +| POST | `/api/users` | Benutzer anlegen `{username, password}` (nur Owner) | +| PUT | `/api/users//password` | Passwort setzen/zurücksetzen `{password}` (nur Owner, invalidiert alte Tokens) | +| DELETE | `/api/users/` | Benutzer + Daten löschen (nur Owner, nicht selbst) | + +**PATCH `/api/sessions/`** akzeptiert zwei unabhängige Blöcke: + +```json +{ "meta": { "date": "2026-09-20", "start_time": "17:30", "end_time": "18:40", + "note": "...", "status": "done", "fields": { "distanceKm": "5,2" } }, + "sets": [ { "exercise": "Push/Bankdrücken KH (je Hand)", "sort": 0, + "weight": "18,5", "reps": "10", "done": 1 } ] } +``` + +- `meta.status` auf `done` ohne `end_time` trägt die Serverzeit ein. +- `sets` wird pro Übung reconciliert (siehe unten). +- Fehler: 400 (Body/Validierung), 404 (unbekannte Session), 500. + +### Datenmodell (SQLite) + +```sql +CREATE TABLE sessions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity TEXT NOT NULL, -- 'gym' | 'run' | 'ride' + day TEXT NOT NULL DEFAULT '', -- Zyklus-Tag (Kraft), z.B. 'Push' + date TEXT NOT NULL, -- YYYY-MM-DD (Serverzeit) + start_time TEXT NOT NULL DEFAULT '', -- HH:MM + end_time TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + fields TEXT NOT NULL DEFAULT '{}', -- Cardio-Felder als JSON + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'done' + created_at TEXT NOT NULL DEFAULT '', + user_id INTEGER NOT NULL DEFAULT 0 -- 0 = offener Modus +); + +CREATE TABLE entries( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- stabile Set-ID + session_id INTEGER NOT NULL, + exercise TEXT NOT NULL, -- Schlüssel 'Tag/Übungsname' + sort INTEGER NOT NULL DEFAULT 0, -- 0-basiert + weight TEXT NOT NULL DEFAULT '', -- Rohstring, '' = leer (nie 0) + reps TEXT NOT NULL DEFAULT '', + done INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE settings( + user_id INTEGER NOT NULL DEFAULT 0, -- 0 = offener Modus + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(user_id, key) +); + +CREATE TABLE users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE tokens( + token_hash TEXT PRIMARY KEY, -- SHA-256 des Bearer-Tokens + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT '' +); +``` + +Gewichte/Reps werden als **Rohstrings** gespeichert ('' = leer, nie "0"), +damit "leer eingeben = leer" gilt. Beide Dezimaltrennzeichen (`,` und `.`) +werden beim Parsen verstanden. + +### Set-Reconcile (Zustands-Isolierung) + +`_reconcile_sets(con, sid, sets)` gruppiert den Payload nach `exercise` und +verarbeitet jede Übung getrennt: + +1. Für jedes übermittelte Set: `UPSERT` über `(session_id, exercise, sort)`. + Bestehende Zeilen behalten ihre ID. +2. Löschung nur von Sätzen **dieser Übung**, deren `sort` im Payload fehlt + (`DELETE ... WHERE exercise=? AND sort NOT IN (...)`). + +Konsequenz: Der Client kann pro Übung einzeln speichern (nur "schmutzige" +Übungen), ohne dass andere Übungen angefasst werden. Das `autofill`-Endpoint +ist die einzige bulk-Ausnahme und bewusst explizit ("Alles zurücksetzen"). + +### Einstellungen + +Defaults in `DEFAULT_SETTINGS`: + +```python +{ + "theme": "dark", # dark | system | light (system folgt dem Geraet) + "oled": False, # reines Schwarz fuer den dunklen Modus + "accent": "white", # Palette-Schluessel (siehe Frontend) + "autoReset": False, # letztes Workout der Kategorie beim Session-Start laden + "planPrefill": False, # Plan-Gewichte (startKg) beim Session-Start vorbefuellen + "showTips": True, # Uebungs-Hinweise anzeigen + "language": "de", # de | en + "setupDone": False, # Ersteinrichtungs-Wizard abgeschlossen (serverweit) + "onboarded": False, # persoenlicher Wizard (Sprache/Darstellung) abgeschlossen + "weightUnit": "kg", # kg | lb (Anzeige-Einheit fuer Gewichte) + "startTab": "today", # Standard-Ansicht beim Start +} +``` + +`GET /api/settings` liefert immer den vollständigen, typ-korrekten Stand +(Booleans als Boolean). Schichtung: Defaults < Server-Standard (user_id=0, +gesetzt im Ersteinrichtungs-Wizard) < eigene Werte des Benutzers. Setzt der +erste Benutzer (Owner) `language`/`accent`, wird das zugleich als +Server-Standard übernommen. `GET /api/meta` enthält `language` (Server- +Standard), damit auch der Login-Screen in der richtigen Sprache erscheint. + +Beim ersten registrierten Benutzer werden die Sessions aus dem offenen Modus +übernommen (MOVE); die Settings werden KOPIERT, damit der Server-Standard +für weitere Benutzer erhalten bleibt. + +### Mehrbenutzer & Authentifizierung + +**Offener Modus:** Solange die `users`-Tabelle leer ist, ist `user_id = 0` +(OPEN_USER) und alle `/api/*`-Endpunkte funktionieren ohne Token - exakt wie +im Einzelmodus. Beim ersten registrierten Benutzer (uid 1 = Owner) werden +vorhandene Sessions/Einstellungen per Migration übernommen +(`UPDATE ... WHERE user_id = 0`). + +**Auth:** Sobald ein Benutzer existiert, liefert `_require_user()` 401, wenn +kein gültiges `Authorization: Bearer ` mitkommt. Öffentlich bleiben +nur `/api/meta`, `/api/login`, `/api/register` sowie die statischen Dateien. + +- Passwörter: PBKDF2-HMAC-SHA256, 200.000 Iterationen, Salt pro Benutzer. +- Tokens: 32-Byte-Zufall, nur der SHA-256-Hash liegt in der DB + (`tokens.token_hash`). +- Passwort ändern: `POST /api/password` (eigenes, mit aktuellem Passwort), + `PUT /api/users//password` (Owner-Reset, invalidiert alte Tokens des + Benutzers). +- Rechte: Benutzerverwaltung (`GET/POST/PUT/DELETE /api/users`) nur für den + Owner (uid 1). Löschen entfernt Sessions, Entries, Settings und Tokens. + +**Daten-Scoping:** Alle Datenabfragen laufen über `_user_clause(user_id)` +(`user_id = ?`), im offenen Modus `user_id = 0`. Sessions, Settings, Reset- +Werte, Statistiken, Export/Import und die Workouts-API sind damit strikt pro +Benutzer getrennt. + +### Workouts-API + +`GET /api/workouts` liefert abgeschlossene Sessions in einer für externe +Auswertungen gedachten Form: + +```json +[ { "id": 5, "activity": "gym", "activity_name": "Gym (Kraft)", + "day": "Push", "date": "2026-09-20", "start_time": "18:00", + "end_time": "19:15", "duration_min": 75, "note": "", + "exercises": [ { "exercise": "Push/Bankdrücken KH (je Hand)", + "name": "Bankdrücken KH (je Hand)", + "sets": [ { "sort": 0, "weight": "18,5", "reps": "10", "done": 1 } ] } ], + "fields": {} } ] +``` + +Filter: `activity`, `from`, `to`, `limit`. Cardio-Workouts haben leere +`exercises` und die Feldwerte in `fields`. + +### Auto-Reset / Autofill + +`last_workout_sets(exercise, exclude_session)`: + +- Findet die jüngste abgeschlossene Session (`status='done'`, ungleich der + aktuellen in-Arbeit-Session) mit mindestens einem Satz, der ein Gewicht hat. +- Liefert nur Sätze mit `weight != ''`, in Original-Reihenfolge. + +`autofill_session(sid, exercises=None)`: + +- `exercises=None` → alle Übungen des Sessions-Tags (aus der Config) plus alle, + die bereits Sätze haben. +- Pro Übung: alte Sätze löschen, gefüllte Sätze des letzten Workouts neu + einfügen, `done = 0` (frische Abhak-Status). +- Übungen **ohne** Verlauf bleiben unangetastet. + +Aufrufstellen: +- `POST /api/sessions` - nur wenn `settings.autoReset` aktiv und die + Aktivität `strength` ist. +- `POST /api/sessions//autofill` - manuell über den Knopf + "Alles zurücksetzen" im Session-Kopf. + +### Export / Import + +- **JSON** (`trainingsplan-export-v2`): `{format, exported_at, config, + sessions}`. Import = Merge per Session-ID: existiert die ID im eigenen + Konto, wird die Session aktualisiert (Entries ersetzt), sonst neu angelegt + - die Original-ID wird übernommen, wenn sie global frei ist; belegte IDs + (z.B. durch einen anderen Benutzer) bekommen eine freie ID oberhalb aller + bekannten. Nichts wird gelöscht, pro Benutzer getrennt. +- **CSV**: eine Zeile pro Kraft-Satz (`kind=set`) bzw. pro Cardio-Session + (`kind=cardio`). Spalten: `session_id, activity, day, date, start_time, + end_time, note, status, kind, exercise, set, weight, reps, done` plus die + Cardio-Feld-Schlüssel aus der Config. Import gruppiert per `session_id` + (oder per Session-Tupel bei handgebauten Dateien). BOM wird beim Import + entfernt. + +--- + +## Frontend + +### Zustandsmodell & Kein-Re-Render-Prinzip + +Ein `SessionModel` (`makeModel`) hält pro Aktivität: + +- `exercises[exerciseKey]` - Array von Set-Objekten `{id, weight, reps, done}` +- `exKeys` - geordnete Liste der Übungen dieser Session (Tag + manuell + hinzugefügte aus anderen Tagen) +- `dirtyEx` - Set der Übungen mit ungespeicherten Änderungen +- `metaDirty` - Notiz/Cardio-Felder geändert + +Die Heute-Ansicht wird **einmal** gebaut. Jede Eingabe schreibt nur in das +eigene Set-Objekt und markiert die Übung als dirty. Es gibt keine globalen +Re-Renders; lokale Rebuilds passieren nur bei expliziten Aktionen +(Satz hinzufügen/entfernen, Übung hinzufügen/entfernen, "Alles +zurücksetzen") - nie während des Tippens. + +### Autosave + +`scheduleAutosave` debounced (700 ms) → `flushAutosave`: + +- sendet **nur** dirty Übungen als `PATCH {sets}` (pro Übung) und dirty Meta + als `PATCH {meta}`. +- Entfernte Übungen werden mit leerem Set-Array übertragen → der Server + löscht ihre Einträge. +- Ein globaler Busy-Flag mit Queue-Activity verhindert parallele Flushes + mehrerer Aktivitäten. +- `finishSession` flusht zuerst, bricht bei nicht gespeicherten Änderungen ab, + setzt dann `status=done` + `end_time` und baut die Karte zurück auf + "Session starten". + +### Set-Kette & Abhak-Status + +`toggleSetDone` hält die Invariante "aktiv ist ein Präfix": + +- Klick auf Set *i* (nicht aktiv) → Sets 1..i aktiv. +- Klick auf Set *i* (aktiv) → Sets i..n inaktiv (rückwärts). +- Gewicht/Reps werden nie verändert; `aria-pressed` und die Ankreuz-Box + (`✓`) werden mitgeführt. + +Der Abhak-Status wird in `entries.done` gespeichert und im Verlauf angezeigt +(✓/□ je Satz) sowie im Inline-Editor als Checkbox bearbeitet. + +### Übung aus anderem Tag + +Der Picker im Session-Kopf listet alle Übungen des Zyklus außer den bereits +enthaltenen. `addExercise` hängt die Übung an `exKeys` und rendert den Block; +`removeExercise` (nur für nicht-Tag-Übungen) entfernt ihn und meldet die +Übung als dirty (leeres Set-Array → Server löscht). Da Übungen erst mit +Werten persistieren, entstehen keine Geister-Einträge. + +### Theme & Akzent + +`applySettings` löst das Theme auf: `system` wird über +`prefers-color-scheme` auf dark/light abgebildet (mit Media-Query-Watch); +ist `oled` aktiv und die Auflösung dark, greifen die rein-schwarzen Tokens. +Danach setzt es `data-theme` und `data-accent` auf ``. Die +Design-Tokens liegen als CSS-Variablen in `:root`; Theme- und +Akzent-Overrides stehen in `html[data-theme=...]` / `html[data-accent=...]`. +Alle Akzente nutzen eine einheitliche dunkle Tinte (`--accent-ink`) mit +dedizierten Hover-/Active-Tönen (mindestens 4,2:1 Kontrast). `showTips` +steuert zusätzlich die Klasse `no-tips` auf der Heute-Ansicht. + +### Sprache (i18n) + +Alle UI-Texte laufen über `t(key)` aus dem `LANG`-Dictionary (de/en). +`lang()` liest `app.settings.language`; ohne Einstellung fällt es auf `de` +zurück. Häufige Server-Fehlermeldungen werden über `eMsg()` lokalisiert +(z.B. "Benutzername oder Kennwort inkorrekt"). Der Login-Screen nutzt die +Server-Standardsprache aus `GET /api/meta` (settings werden erst nach Login +geladen). + +### Ersteinrichtungs-Wizard & Onboarding + +- **Wizard** (`renderSetupWizard`): einmalig, solange `setupDone` fehlt. + Schritte mit expliziter Navigation (Zurück / Weiter / Einrichtung + abschließen), Auswahl springt NICHT automatisch weiter; eine + Segment-Fortschrittsleiste (`wizardProgress`) oben zeigt den Stand: + 1. Sprache (serverweit) - kommt zuerst, damit der Wizard lesbar ist + 2. Einzel- oder Mehrbenutzermodus (bei Mehrbenutzer wird der erste Benutzer + direkt angelegt) + 3. Darstellung: Theme (Dunkel/System/Hell) + OLED-Schalter + Akzentfarbe + Abschließen persistiert alles und setzt `setupDone` (+ `onboarded` für den + Owner). +- **Onboarding**: einloggende Benutzer ohne `onboarded` durchlaufen einmalig + Sprache -> Darstellung für ihr eigenes Konto. + +### Session-Vorbefuellung beim Start + +`fill_session_start` befüllt eine frische Session pro Übung: +- `autoReset` (Letztes Training vorbefüllen): letztes abgeschlossenes Workout + der Übung (nur gefüllte Sätze) - hat Vorrang. +- `planPrefill` (Plan-Gewichte vorbefüllen): startKg aus der Config, Anzahl + wie im Plan, auch ohne Verlauf. +Übungen ohne Quelle bleiben leer (der Client seedet Plan-Sätze leer). + +### Uebungen sortieren & Wochen-Uebersicht + +- **Sortieren**: Drag & Drop auf dem Uebungs-Block (`attachDragReorder`); + die Reihenfolge wird in `session.fields.exerciseOrder` gespeichert und bei + `makeModel` wiederhergestellt. +- **Wochen-Uebersicht**: im Verlauf zwischen Liste und Woche umschaltbar; + eine 7-Tage-Spalte (Montag-Sonntag) mit den Sessions des Tages, blätterbar + über Vorige/Nächste Woche. + +--- + +## config.json - Schema + +```jsonc +{ + "profile": { "name": "", "weightUnit": "kg", "repsLabel": "reps" }, + "activities": [ + { + "id": "gym", // eindeutig + "name": "Gym (Kraft)", + "type": "strength", // strength | cardio + "active": true, + "displayOrder": 1, + "cycle": { // nur strength + "name": "Push/Pull/Legs", + "days": [ + { + "key": "Push", // Teil des Übungs-Schlüssels + "title": "Push", + "exercises": [ + { + "name": "Bankdrücken KH (je Hand)", + "sets": 4, // Zahl = uniform ODER Array für einzelne Sätze: + // "sets": [ { "weight": 18, "reps": 8 }, { "weight": 15, "reps": 10 } ], + "reps": "8-10", // Ziel, als Hinweis + "startKg": 18, // Start-Gewicht, als Hinweis + "tags": ["brust", "kh"], + "info": "Erklärung + passende Maschine (Fragezeichen-Overlay)" + } + ] + } + ] + } + }, + { + "id": "run", // Cardio + "name": "Laufen", + "type": "cardio", + "displayOrder": 2, + "fieldTemplates": [ + { "key": "distanceKm", "label": "Distanz", "unit": "km", + "kind": "number", "step": 0.1 }, + { "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number" }, + { "key": "pacePerKm", "label": "Pace", "unit": "min/km", "kind": "pace" }, + { "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "optional": true }, + { "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "optional": true } + ] + } + ] +} +``` + +Der Server normalisiert die Struktur einmalig beim Start +(`normalize_config`). Verlaufsdaten hängen an den Übungsschlüsseln +`Tag/Übungsname` - **Übungen umbenennen bricht die Zuordnung zum alten +Verlauf**. + +--- + +## Deployment + +### Environment-Variablen + +| Variable | Default | Bedeutung | +|---|---|---| +| `PORT` | `8080` | HTTP-Port | +| `TRAINING_STATE_DIR` | `./data` | Verzeichnis für `state.db` | + +Port auch als erstes Argument: `python3 server.py 8080`. + +### Docker / CasaOS + +- `Dockerfile`: `python:3.13-slim`, `WORKDIR /app`, `VOLUME /app/data`, + `HEALTHCHECK` gegen `/api/meta`. +- `docker-compose.yml`: bindet `/DATA/AppData/trainingsplan/data` als echten + Ordner (CasaOS-tauglich, sichtbar/backupbar). Optional kann `config.json` + als read-only Mount eingebunden werden, um den Plan ohne Image-Rebuild zu + ändern (danach Container neu starten). + +--- + +## Tests + +Vier Testläufe unter `tests/` (laufen gegen einen echten Server-Subprozess +mit frischem `TRAINING_STATE_DIR`): + +| Suite | Checks | Inhalt | +|---|---|---| +| `test_api.py` | 39 | Session-Lebenszyklus, Set-Isolierung, stabile IDs, Reset, Statistik, Export/Import, Fehlerfälle (offener Modus) | +| `test_api_settings.py` | 18 | Settings-Defaults/-Persistenz/-Typen, Auto-Reset (nur gefüllte Sets), Autofill, Plan-Gewichte vorbefüllen (mit/ohne Verlauf) | +| `test_auth.py` | 43 | Register/Login/Logout, 401-Gate, Alt-Daten-Migration, Benutzerverwaltung, Passwort ändern/zurücksetzen, Server-Sprach-Standard, strikte Daten-Isolation, Workouts-API, Demo-Import, Benutzer-Löschung | +| `test_ui.js` (jsdom) | 28 | Ersteinrichtungs-Wizard (Zurück/Weiter), Session starten, Tippen ohne Fokus-Verlust, Set-Button/Box, Set-Kette, Übung aus anderem Tag, Drag & Drop sortieren, Wochenübersicht, Theme/System/OLED/Akzent, Toggles, Auto-Reset-Flow | +| `test_ui_auth.js` (jsdom) | 23 | Wizard (Modus, Sprache, Darstellung), Sprachwechsel live, Onboarding, Login-Gate mit Inline-Fehler, eingeloggter Start, Einstellungen (Konto-Kennwort, Admin nicht in Benutzerliste) | + +`test_ui.js` benötigt jsdom: `npm install jsdom@22.1.0` und Start mit +`NODE_PATH=<...>/node_modules node tests/test_ui.js`. + +--- + +## Erweitern + +- **Neue Übung/neuen Tag:** in `config.json` ergänzen (siehe Schema). Server + neu starten. +- **Neues Cardio-Feld:** `fieldTemplates` erweitern; Unit/Label kommen aus der + Config, der Export/Import baut die CSV-Spalten automatisch daraus. +- **Neue Einstellung:** `DEFAULT_SETTINGS` in `server.py` + Eintrag in + `renderSettingsView` in `app.js`. +- **Neuer Theme-/Akzentwert:** CSS-Override in `styles.css` ergänzen; den + Akzent zusätzlich in `ACCENTS` (app.js) für die Swatches registrieren. +- **Neue API-Route:** Handler in `server.py` unter `do_GET`/`do_POST`/ + `do_PATCH`/`do_PUT`/`do_DELETE` ergänzen. \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..457d48a --- /dev/null +++ b/app.js @@ -0,0 +1,2358 @@ +"use strict"; + +/* =========================================================================== + Trainingsplan - Frontend + + Kern-Entscheidungen: + - Das DOM der Heute-Ansicht wird EINMAL gebaut. Tippen schreibt nur in das + eigene Set-State-Objekt und loest einen Debounce-Autosave aus. Es gibt + keine Re-Renders waehrend der Eingabe -> Fokus/Cursor bleiben intakt. + - Aenderungen werden pro Uebung (exerciseKey) nachgetragen. Nur Uebungen + mit ungespeicherten Werten werden an den Server geschickt (dirtyEx). + - Saetze haben keine IDs im Client: sie werden ueber (exercise, sort) + mit dem Server abgeglichen; der Server vergibt stabile DB-IDs. + =========================================================================== */ + +const app = { + cfg: null, + meta: null, + settings: null, + me: null, + models: {}, // activityId -> SessionModel + cards: {}, // activityId -> Karten-Element + pickedDay: {}, // activityId -> gewaehlter Zyklus-Tag + saveInd: {}, // activityId -> Speicher-Indikator + autosaveTimer: {}, + flushBusy: false, + flushQueuedActivity: null, + toastTimer: null, +}; + +const BRAND = "REKORD"; + +const WD = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; + +const ACCENTS = { + amber: "#e0a33c", + orange: "#e4582e", + red: "#d64541", + green: "#3fae5a", + teal: "#2aa198", + cyan: "#25b8cc", + blue: "#4a86c8", + violet: "#9a6fd0", + pink: "#e0567f", + white: "#ffffff", +}; + +const LANG = { + de: { + tab_today: "Heute", tab_history: "Verlauf", tab_stats: "Statistik", + tab_data: "Daten", tab_settings: "Einstellungen", + today_header: "Heute · ", + start_session: "Session starten", finish_session: "Session abschließen", + discard: "Verwerfen", confirm_discard: "Wirklich verwerfen?", + note_placeholder: "Notiz (optional)", + started: "gestartet ", saved: "gespeichert", saving: "wird gespeichert ...", + save_error: "Fehler beim Speichern", + save_failed: "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen.", + pick_day: "Tag wählen", add_exercise: "Übung hinzufügen ...", + add_set: "Satz hinzufügen", remove_exercise: "Entfernen", + set_label: "Set", reset_set: "Reset", + set_done_aria: "Satz {n} abhaken", + remove_set_aria: "Satz entfernen", note_label: "Notiz", + reset_set_title: "Auf letztes Training zurücksetzen", + reset_all: "Alles zurücksetzen", + reset_all_title: "Alle Sätze auf das letzte abgeschlossene Workout setzen", + weight: "Gewicht", reps: "Reps", + set_count: "Sätze", start_hint: "Start", + info_aria: "Erklärung zu ", + in_work: "In Arbeit seit {d} - noch nicht abgeschlossen.", + move_today: "Auf heute übertragen", + day_missing: "Session-Tag '{d}' ist nicht mehr in config.json.", + kind_strength: "Kraft", kind_cardio: "Cardio", + no_activities: "Keine Aktivitäten konfiguriert. Siehe config.json.", + close: "Schließen", finish: "Fertig", + remove_exercise_aria: "Übung aus der Session entfernen", + reset_all_done: "Alle Sätze auf das letzte abgeschlossene Workout zurückgesetzt.", + session_saved: "Session gespeichert und abgeschlossen.", + session_discarded: "Session verworfen.", + session_deleted: "Session gelöscht.", + session_updated: "Session aktualisiert.", + no_prev_measure: "Keine frühere Messung für Satz {n}", + empty_label: "leer", + edit: "Bearbeiten", delete: "Löschen", sure: "Sicher?", + note_prefix: "Notiz: ", time_unknown: "Zeit unbekannt", + from_time: "ab ", to_time: " bis ", + date: "Datum", start: "Start", end: "Ende", + save: "Speichern", cancel: "Abbrechen", set_n: "Satz {n}", + no_history: "Noch keine abgeschlossenen Sessions. Im Tab 'Heute' eine Session starten und abschließen.", + no_values: "keine Werte", + choose_exercise: "Übung wählen", + col_date: "Datum", col_best: "Bestes Gewicht", col_reps: "Reps", + col_diff: "Diff. zur ersten Messung", + no_stats: "Noch keine Messungen für diese Übung.", + no_strength: "Keine Kraft-Übungen konfiguriert.", + export: "Export", import: "Import", + export_json: "Export JSON", export_csv: "Export CSV", + import_json: "JSON einspielen", import_csv: "CSV einspielen", + data_desc: "Der komplette Stand (auch in Arbeit befindliche Sessions) als JSON oder CSV. CSV-Spalten: session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, done sowie die Cardio-Felder aus der config.", + import_desc: "Import ergänzt Sessions oder aktualisiert sie anhand ihrer ID. Es wird nichts gelöscht. Für eine Wiederherstellung am besten in einen frischen Stand importieren.", + file_first: "Zuerst eine Datei wählen.", + import_ok: "{c} Sessions angelegt, {u} aktualisiert.", + import_fail: "Import fehlgeschlagen: {m}", + appearance: "Darstellung", theme_label: "Farbthema", + dark: "Dunkel", light: "Hell", oled: "OLED", + accent_label: "Akzentfarbe", accent_aria: "Akzent {k}", + language_label: "Sprache", german: "Deutsch", english: "English", + behavior: "Verhalten", auto_reset: "Auto-Reset", + auto_reset_desc: "Beim Start einer Session das letzte Workout der jeweiligen Kategorie automatisch laden (nur ausgefüllte Sätze).", + show_tips: "Übungs-Hinweise", + show_tips_desc: "Zeige Hinweise wie '4 Sätze · 8-10 reps · Start 18 kg' neben dem Übungsnamen.", + reset_defaults: "Auf Standard zurücksetzen", + defaults_reset: "Einstellungen zurückgesetzt.", + setting_saved: "Einstellung gespeichert.", + account: "Konto", account_desc: "Angemeldet als '{u}'. Kennwort ändern:", + change_pw: "Kennwort ändern", current_pw: "Aktuelles Kennwort", + new_pw: "Neues Kennwort", pw_changed: "Kennwort geändert.", + users: "Benutzer", setup_users: "Benutzer einrichten", + users_solo_desc: "Du bist im Einzelmodus (ohne Anmeldung). Lege den ersten Benutzer an, um Login und mehrere Benutzer zu aktivieren.", + first_user: "Ersten Benutzer anlegen", + users_desc: "Weitere Benutzer mit eigenen Daten anlegen, Kennwörter zurücksetzen oder Benutzer löschen.", + owner: "Besitzer", create: "Anlegen", apply: "Übernehmen", + cancel_change: "Änderung abbrechen", + user_created: "Benutzer '{u}' angelegt.", user_deleted: "Benutzer gelöscht.", + pw_set: "Kennwort von '{u}' gesetzt.", + only_user: "Du bist der einzige Benutzer.", + welcome: "Willkommen! Wähle, wie du die App nutzen möchtest:", + multi_mode: "Mehrbenutzermodus", + multi_desc: "Jeder Benutzer hat Benutzername und Kennwort. Der erste Benutzer ist Administrator und kann Kennwörter ändern sowie Benutzer anlegen und löschen - auch für Einzelnutzer von Vorteil.", + solo_mode: "Einzelmodus", + solo_desc: "Ohne Anmeldung, jeder kann auf die Instanz zugreifen. Der Mehrbenutzermodus kann jederzeit in den Einstellungen aktiviert werden.", + login: "Anmelden", username: "Benutzername", password: "Kennwort", + login_sub: "Anmelden, um deine Trainingsdaten zu sehen.", + lang_step: "Sprache festlegen", + lang_server: "Diese Sprache gilt serverweit als Standard.", + color_step: "Akzentfarbe festlegen", + color_server: "Wird als Standard-Akzent übernommen.", + onboard_title: "Persönliche Einstellungen", + onboard_desc: "Wähle einmalig Sprache und Akzentfarbe für dein Konto.", + back: "Zurück", next: "Weiter", finish_setup: "Einrichtung abschließen", + system: "System", + oled_toggle: "OLED-Dark", + oled_desc: "Reines Schwarz für den dunklen Modus.", + last_workout: "Letztes Training vorbefüllen", + last_workout_desc: "Neue Session startet mit dem letzten abgeschlossenen Workout der Kategorie (nur ausgefüllte Sätze).", + plan_prefill: "Plan-Gewichte vorbefüllen", + plan_prefill_desc: "Neue Session startet mit den Start-Gewichten aus der Config (startKg).", + weight_unit: "Gewichtseinheit", start_tab: "Start-Ansicht", + week: "Woche", list_view: "Liste", no_sessions_week: "keine Sessions", + prog_lang: "Sprache", prog_mode: "Modus", prog_appearance: "Darstellung", + reorder_hint: "Übungen per Ziehen sortieren", + prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom", + wizard_done: "Alles erledigt - die App ist bereit.", + logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.", + app_load_fail: "App konnte nicht geladen werden: {m}", + }, + en: { + tab_today: "Today", tab_history: "History", tab_stats: "Statistics", + tab_data: "Data", tab_settings: "Settings", + today_header: "Today · ", + start_session: "Start session", finish_session: "Finish session", + discard: "Discard", confirm_discard: "Really discard?", + note_placeholder: "Note (optional)", + started: "started ", saved: "saved", saving: "saving ...", + save_error: "Save failed", + save_failed: "Could not save changes. Please try again.", + pick_day: "Choose day", add_exercise: "Add exercise ...", + add_set: "Add set", remove_exercise: "Remove", + set_label: "Set", reset_set: "Reset", + set_done_aria: "Check off set {n}", + remove_set_aria: "Remove set", note_label: "Note", + reset_set_title: "Reset to last workout", + reset_all: "Reset all", + reset_all_title: "Reset all sets to the last completed workout", + weight: "Weight", reps: "Reps", + set_count: "sets", start_hint: "Start", + info_aria: "About ", + in_work: "In progress since {d} - not finished yet.", + move_today: "Move to today", + day_missing: "Session day '{d}' is no longer in config.json.", + kind_strength: "Strength", kind_cardio: "Cardio", + no_activities: "No activities configured. See config.json.", + close: "Close", finish: "Done", + remove_exercise_aria: "Remove exercise from session", + reset_all_done: "All sets reset to the last completed workout.", + session_saved: "Session saved and finished.", + session_discarded: "Session discarded.", + session_deleted: "Session deleted.", + session_updated: "Session updated.", + no_prev_measure: "No previous measurement for set {n}", + empty_label: "empty", + edit: "Edit", delete: "Delete", sure: "Sure?", + note_prefix: "Note: ", time_unknown: "time unknown", + from_time: "from ", to_time: " to ", + date: "Date", start: "Start", end: "End", + save: "Save", cancel: "Cancel", set_n: "Set {n}", + no_history: "No completed sessions yet. Start and finish a session in the 'Today' tab.", + no_values: "no values", + choose_exercise: "Choose exercise", + col_date: "Date", col_best: "Best weight", col_reps: "Reps", + col_diff: "Diff from first measurement", + no_stats: "No measurements for this exercise yet.", + no_strength: "No strength exercises configured.", + export: "Export", import: "Import", + export_json: "Export JSON", export_csv: "Export CSV", + import_json: "Import JSON", import_csv: "Import CSV", + data_desc: "Export the complete state (including sessions in progress) as JSON or CSV. CSV columns: session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, done plus the cardio fields from the config.", + import_desc: "Import adds sessions or updates them by their ID. Nothing is deleted. For a restore, import into a fresh state.", + file_first: "Choose a file first.", + import_ok: "{c} sessions created, {u} updated.", + import_fail: "Import failed: {m}", + appearance: "Appearance", theme_label: "Color theme", + dark: "Dark", light: "Light", oled: "OLED", + accent_label: "Accent color", accent_aria: "Accent {k}", + language_label: "Language", german: "German", english: "English", + behavior: "Behavior", auto_reset: "Auto-reset", + auto_reset_desc: "When starting a session, automatically load the last workout of the category (only filled sets).", + show_tips: "Exercise hints", + show_tips_desc: "Show hints like '4 sets · 8-10 reps · Start 18 kg' next to the exercise name.", + reset_defaults: "Reset to defaults", + defaults_reset: "Settings reset.", + setting_saved: "Setting saved.", + account: "Account", account_desc: "Logged in as '{u}'. Change password:", + change_pw: "Change password", current_pw: "Current password", + new_pw: "New password", pw_changed: "Password changed.", + users: "Users", setup_users: "Set up users", + users_solo_desc: "You are in single-user mode (no login). Create the first user to enable login and multiple users.", + first_user: "Create first user", + users_desc: "Create additional users with their own data, reset passwords, or delete users.", + owner: "Owner", create: "Create", apply: "Apply", + cancel_change: "Cancel change", + user_created: "User '{u}' created.", user_deleted: "User deleted.", + pw_set: "Password for '{u}' set.", + only_user: "You are the only user.", + welcome: "Welcome! Choose how you want to use the app:", + multi_mode: "Multi-user mode", + multi_desc: "Each user has a username and password. The first user is the administrator and can change passwords as well as create and delete users - also an advantage for single users.", + solo_mode: "Single-user mode", + solo_desc: "No login; anyone can access the instance. Multi-user mode can be activated anytime in the settings.", + login: "Log in", username: "Username", password: "Password", + login_sub: "Log in to see your training data.", + lang_step: "Set language", + lang_server: "This language is the server-wide default.", + color_step: "Set accent color", + color_server: "Will be used as the default accent.", + onboard_title: "Personal settings", + onboard_desc: "Choose language and accent color once for your account.", + back: "Back", next: "Next", finish_setup: "Finish setup", + system: "System", + oled_toggle: "OLED dark", + oled_desc: "Pure black for the dark mode.", + last_workout: "Prefill last workout", + last_workout_desc: "New sessions start with the last completed workout of the category (only filled sets).", + plan_prefill: "Prefill plan weights", + plan_prefill_desc: "New sessions start with the start weights from the config (startKg).", + weight_unit: "Weight unit", start_tab: "Start view", + week: "Week", list_view: "List", no_sessions_week: "no sessions", + prog_lang: "Language", prog_mode: "Mode", prog_appearance: "Appearance", + reorder_hint: "Drag to reorder exercises", + prev_week: "Previous week", next_week: "Next week", week_of: "Week of", + wizard_done: "All done - the app is ready.", + logged_in: "Logged in.", user_created_toast: "User created.", + app_load_fail: "Could not load the app: {m}", + }, +}; + +function lang() { + const l = app.settings && app.settings.language; + return (l === "en") ? "en" : "de"; +} + +function t(key, vars) { + const table = LANG[lang()] || LANG.de; + let s = table[key]; + if (s === undefined) s = LANG.de[key] || key; + if (vars) { + for (const k of Object.keys(vars)) { + s = s.replace("{" + k + "}", String(vars[k])); + } + } + return s; +} + +/* Uebersetze haeufige Server-Fehlermeldungen (de -> aktuelle Sprache). */ +const ERR_DE = { + "Benutzername oder Passwort falsch": ["login_fail_de", "login_fail_en"], + "Aktuelles Passwort ist falsch": ["current_pw_wrong_de", "current_pw_wrong_en"], + "Passwort zu kurz (mindestens 4 Zeichen)": ["pw_short_de", "pw_short_en"], + "Benutzername zu kurz (mindestens 2 Zeichen)": ["user_short_de", "user_short_en"], + "Benutzername ist bereits vergeben": ["user_taken_de", "user_taken_en"], + "Registrierung geschlossen, es existiert bereits ein Benutzer": ["reg_closed_de", "reg_closed_en"], + "Anmeldung erforderlich": ["auth_required_de", "auth_required_en"], + "nur der erste Benutzer": ["owner_only_de", "owner_only_en"], + "benutzer nicht gefunden": ["user_not_found_de", "user_not_found_en"], +}; +const ERR_L10N = { + de: { + login_fail: "Benutzername oder Kennwort inkorrekt", + current_pw_wrong: "Aktuelles Kennwort ist falsch", + pw_short: "Kennwort zu kurz (mindestens 4 Zeichen)", + user_short: "Benutzername zu kurz (mindestens 2 Zeichen)", + user_taken: "Benutzername ist bereits vergeben", + reg_closed: "Registrierung geschlossen, es existiert bereits ein Benutzer", + auth_required: "Anmeldung erforderlich", + owner_only: "nur der erste Benutzer", + user_not_found: "Benutzer nicht gefunden", + }, + en: { + login_fail: "Incorrect username or password", + current_pw_wrong: "Current password is incorrect", + pw_short: "Password too short (at least 4 characters)", + user_short: "Username too short (at least 2 characters)", + user_taken: "Username is already taken", + reg_closed: "Registration is closed, a user already exists", + auth_required: "Login required", + owner_only: "only the first user", + user_not_found: "User not found", + }, +}; + +function eMsg(e) { + const raw = (e && e.message) || String(e); + const map = ERR_DE[raw]; + if (map) return ERR_L10N[lang()][map[0].replace("_de", "")]; + return raw; +} + + +/* --------------------------------------------------------------------------- + Kleine Helfer + ------------------------------------------------------------------------- */ + +function $(sel, root) { + return (root || document).querySelector(sel); +} + +function $$(sel, root) { + return Array.from((root || document).querySelectorAll(sel)); +} + +function el(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text !== undefined && text !== null) n.textContent = text; + return n; +} + +async function api(path, opts) { + opts = opts || {}; + const headers = opts.headers || {}; + const token = localStorage.getItem("tp_token"); + if (token) headers.Authorization = "Bearer " + token; + opts.headers = headers; + const res = await fetch(path, opts); + if (!res.ok) { + let msg = "HTTP " + res.status; + try { + const j = await res.json(); + if (j && j.error) msg = j.error; + } catch (_) {} + const err = new Error(msg); + err.status = res.status; + throw err; + } + return res.json(); +} + +function jsonOpts(method, body) { + return { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; +} + +function toast(msg, kind) { + const t = $("#toast"); + t.textContent = msg; + t.className = "toast" + (kind ? " is-" + kind : ""); + t.hidden = false; + clearTimeout(app.toastTimer); + app.toastTimer = setTimeout(() => { t.hidden = true; }, 2800); +} + +function flashError(e) { + toast(eMsg(e), "error"); +} + +function fmtDate(iso) { + const p = String(iso).split("-"); + return p.length === 3 ? p[2] + "." + p[1] + "." + p[0] : String(iso); +} + +function fmtDateLong(iso) { + const p = String(iso).split("-"); + if (p.length !== 3) return String(iso); + const dt = new Date(+p[0], +p[1] - 1, +p[2]); + return WD[dt.getDay()] + " " + fmtDate(iso); +} + +function fmtKg(v) { + return String(Number(v)).replace(".", ","); +} + +function cfgActivity(id) { + return app.cfg.activities.find(a => a.id === id) || null; +} + +function findDay(a, dayKey) { + return a.cycle ? a.cycle.days.find(d => d.key === dayKey) || null : null; +} + +function allExercisesMap(a) { + const map = {}; + if (!a.cycle) return map; + for (const d of a.cycle.days) { + for (const ex of d.exercises) map[ex.key] = ex; + } + return map; +} + +/* --------------------------------------------------------------------------- + SessionModel: der gesamte Client-Zustand einer laufenden Session. + Jede Uebung hat ihr eigenes Set-Array; nichts wird global neu gebaut. + exKeys ist die geordnete Liste der Uebungen dieser Session (Tag + manuell + hinzugefuegte aus anderen Tagen). + ------------------------------------------------------------------------- */ + +function makeModel(a, sess) { + const m = { + id: sess.id, + activity: a.id, + day: sess.day || "", + date: sess.date, + start_time: sess.start_time || "", + note: sess.note || "", + fields: sess.fields || {}, + status: sess.status || "active", + exercises: {}, // exerciseKey -> [ {id,weight,reps,done} ] + exKeys: [], // geordnete Uebungs-Schluessel dieser Session + exBlocks: {}, // exerciseKey -> Block-Element + exChecks: {}, // exerciseKey -> [check-Button] + exByKey: {}, // exerciseKey -> Konfig-Uebung + dirtyEx: new Set(), // Uebungen mit ungespeicherten Aenderungen + metaDirty: false, + }; + m.exByKey = allExercisesMap(a); + const byEx = {}; + for (const e of sess.entries || []) { + (byEx[e.exercise] = byEx[e.exercise] || []).push(e); + } + const keys = []; + const configEx = {}; + const day = findDay(a, m.day); + if (day) { + for (const ex of day.exercises) { + configEx[ex.key] = ex; + keys.push(ex.key); + } + } + for (const k of Object.keys(byEx)) if (!keys.includes(k)) keys.push(k); + m.exKeys = keys; + // Manuell sortierte Uebungen (Drag & Drop) wiederherstellen + if (Array.isArray(m.fields.exerciseOrder) && m.fields.exerciseOrder.length) { + const ordered = m.fields.exerciseOrder.filter(k => keys.includes(k)); + for (const k of keys) if (!ordered.includes(k)) ordered.push(k); + m.exKeys = ordered; + } + for (const k of keys) { + const rows = byEx[k] || []; + if (rows.length) { + m.exercises[k] = rows.map(r => ({ + id: r.id, weight: r.weight || "", reps: r.reps || "", done: r.done ? 1 : 0, + })); + } else if (configEx[k]) { + // Frische Session: leere Set-Zeilen aus der Config als Vorlage. + // Gespeichert wird erst, wenn der Nutzer Werte eingibt. + const cfg = configEx[k].sets; + const count = cfg.list ? cfg.list.length : (cfg.count || 0); + m.exercises[k] = Array.from({ length: count }, + () => ({ id: null, weight: "", reps: "", done: 0 })); + } else { + m.exercises[k] = []; + } + } + return m; +} + +/* --------------------------------------------------------------------------- + Heute - Aufbau + ------------------------------------------------------------------------- */ + +async function loadToday() { + const res = await api("/api/sessions?status=active"); + const byAct = {}; + for (const s of res) byAct[s.activity] = s; + app.models = {}; + for (const a of app.cfg.activities) { + if (byAct[a.id]) app.models[a.id] = makeModel(a, byAct[a.id]); + } + renderToday(); +} + +function renderToday() { + const view = $("#view-today"); + view.innerHTML = ""; + const acts = app.cfg.activities.filter(a => a.active); + if (!acts.length) { + view.appendChild(el("div", "empty-box", + t("no_activities"))); + return; + } + for (const a of acts) { + const card = el("section", "act-card"); + app.cards[a.id] = card; + const title = el("div", "act-title"); + title.appendChild(el("h2", "", a.name)); + title.appendChild(el("span", "act-kind", a.type === "strength" ? t("kind_strength") : t("kind_cardio"))); + card.appendChild(title); + renderActivityBody(card, a); + view.appendChild(card); + } +} + +function renderActivityBody(card, a) { + const model = app.models[a.id]; + if (model) { + if (a.type === "strength") renderStrengthSession(card, a, model); + else renderCardioSession(card, a, model); + } else { + renderStartPicker(card, a); + } +} + +function rebuildCard(a) { + const card = app.cards[a.id]; + if (!card) return; + const title = $(".act-title", card); + card.innerHTML = ""; + card.appendChild(title); + renderActivityBody(card, a); +} + +function renderStartPicker(card, a) { + const box = el("div", "start-box"); + if (a.type === "strength" && a.cycle && a.cycle.days.length) { + box.appendChild(el("div", "picker-label", t("pick_day"))); + const wrap = el("div", "day-picker"); + if (!app.pickedDay[a.id]) app.pickedDay[a.id] = a.cycle.days[0].key; + for (const d of a.cycle.days) { + const b = el("button", "day-btn" + (d.key === app.pickedDay[a.id] ? " is-active" : ""), d.title); + b.type = "button"; + b.addEventListener("click", () => { + app.pickedDay[a.id] = d.key; + for (const other of $$(".day-btn", wrap)) other.classList.remove("is-active"); + b.classList.add("is-active"); + }); + wrap.appendChild(b); + } + box.appendChild(wrap); + } + const start = el("button", "btn btn-primary btn-block", t("start_session")); + start.type = "button"; + start.addEventListener("click", () => startSession(a)); + box.appendChild(start); + card.appendChild(box); +} + +async function startSession(a) { + const body = { activity: a.id }; + if (a.type === "strength") body.day = app.pickedDay[a.id]; + try { + const sess = await api("/api/sessions", jsonOpts("POST", body)); + app.models[a.id] = makeModel(a, sess); + rebuildCard(a); + } catch (e) { + flashError(e); + } +} + +function renderStrengthSession(card, a, model) { + const day = findDay(a, model.day); + if (!day) { + const nb = el("div", "notice", + t("day_missing", { d: model.day })); + nb.appendChild(transferBtn(a, model, t("move_today"))); + nb.appendChild(discardBtn(a, model)); + card.appendChild(nb); + return; + } + card.appendChild(sessionHead(a, model, day.title, day)); + + if (model.date !== app.meta.today) { + const nb = el("div", "notice", + t("in_work", { d: fmtDate(model.date) })); + nb.appendChild(transferBtn(a, model, t("move_today"))); + card.appendChild(nb); + } + + const dayKeys = day.exercises.map(x => x.key); + for (const k of model.exKeys) { + const ex = model.exByKey[k] || { + key: k, name: k, sets: { count: 0 }, target: "", startKg: "", + info: "", tags: [], + }; + card.appendChild(buildExBlock(a, model, ex, !dayKeys.includes(k))); + } +} + +function renderCardioSession(card, a, model) { + card.appendChild(sessionHead(a, model, "", null)); + if (model.date !== app.meta.today) { + const nb = el("div", "notice", + t("in_work", { d: fmtDate(model.date) })); + nb.appendChild(transferBtn(a, model, t("move_today"))); + card.appendChild(nb); + } +} + +function transferBtn(a, model, label) { + const b = el("button", "btn btn-small", label); + b.type = "button"; + b.addEventListener("click", async () => { + try { + await api("/api/sessions/" + model.id, + jsonOpts("PATCH", { meta: { date: app.meta.today } })); + model.date = app.meta.today; + rebuildCard(a); + } catch (e) { flashError(e); } + }); + return b; +} + +function discardBtn(a, model) { + const b = el("button", "btn btn-danger-ghost btn-small", t("discard")); + confirmify(b, () => discardSession(a, model), t("confirm_discard")); + return b; +} + +function sessionHead(a, model, dayTitle, day) { + const head = el("div", "session-head"); + if (dayTitle) head.appendChild(el("span", "session-day", dayTitle)); + head.appendChild(el("span", "session-when", "gestartet " + (model.start_time || "?"))); + head.appendChild(saveIndicator(a)); + + if (a.type === "cardio") { + const wrap = el("div", "cardio-fields"); + wrap.className = "cardio-fields"; + for (const t of a.fieldTemplates || []) { + const im = t.kind === "number" ? "decimal" : "text"; + wrap.appendChild(makeFieldBox(t.label, t.unit, model.fields[t.key] || "", v => { + model.fields[t.key] = v; + model.metaDirty = true; + scheduleAutosave(model); + }, im)); + } + head.appendChild(wrap); + } else if (a.cycle) { + head.appendChild(exercisePicker(a, model, day)); + } + + const noteBox = el("label", "note-field"); + const noteInput = el("input", "note-input"); + noteInput.type = "text"; + noteInput.placeholder = t("note_placeholder"); + noteInput.value = model.note || ""; + noteInput.addEventListener("input", () => { + model.note = noteInput.value.trim(); + model.metaDirty = true; + scheduleAutosave(model); + }); + noteBox.appendChild(noteInput); + head.appendChild(noteBox); + + if (a.type === "strength") { + const resetAll = el("button", "btn btn-small", t("reset_all")); + resetAll.type = "button"; + resetAll.title = t("reset_all_title"); + resetAll.addEventListener("click", () => resetAllSets(a, model)); + head.appendChild(resetAll); + } + + const fin = el("button", "btn btn-primary", t("finish_session")); + fin.type = "button"; + fin.addEventListener("click", () => finishSession(a, model)); + head.appendChild(fin); + + head.appendChild(discardBtn(a, model)); + return head; +} + +/* Uebung aus einem anderen Tag zur laufenden Session hinzufuegen */ + +function exercisePicker(a, model, day) { + const sel = el("select", "ex-picker"); + const none = el("option", "", t("add_exercise")); + none.value = ""; + sel.appendChild(none); + for (const d of a.cycle.days) { + const available = d.exercises.filter(ex => !model.exKeys.includes(ex.key)); + if (!available.length) continue; + const g = el("optgroup"); + g.label = d.title; + for (const ex of available) { + const opt = el("option", "", ex.name); + opt.value = ex.key; + g.appendChild(opt); + } + sel.appendChild(g); + } + sel.addEventListener("change", () => { + if (sel.value) addExercise(a, model, sel.value); + sel.value = ""; + }); + return sel; +} + +function addExercise(a, model, exKey) { + if (model.exKeys.includes(exKey)) return; + const ex = model.exByKey[exKey]; + if (!ex) return; + model.exKeys.push(exKey); + const cfg = ex.sets; + const count = cfg.list ? cfg.list.length : (cfg.count || 1); + model.exercises[exKey] = Array.from({ length: count }, + () => ({ id: null, weight: "", reps: "", done: 0 })); + const card = app.cards[model.activity]; + if (card) card.appendChild(buildExBlock(a, model, ex, true)); + model.dirtyEx.add(exKey); + scheduleAutosave(model); +} + +function removeExercise(a, model, exKey) { + const block = model.exBlocks[exKey]; + if (block) block.remove(); + model.exKeys = model.exKeys.filter(k => k !== exKey); + delete model.exercises[exKey]; + model.dirtyEx.add(exKey); + scheduleAutosave(model); +} + +/* Uebungen per Drag & Drop sortieren (Reihenfolge wird in session.fields + gespeichert, damit sie Reloads uebersteht). */ + +function reorderExercise(a, model, fromKey, toKey) { + if (!fromKey || fromKey === toKey) return; + const keys = model.exKeys; + const from = keys.indexOf(fromKey); + const to = keys.indexOf(toKey); + if (from < 0 || to < 0) return; + keys.splice(from, 1); + keys.splice(to, 0, fromKey); + const card = app.cards[model.activity]; + if (card) { + const blocks = keys.map(k => model.exBlocks[k]).filter(Boolean); + for (const b of blocks) card.appendChild(b); + } + model.fields = Object.assign({}, model.fields, { exerciseOrder: keys.slice() }); + model.metaDirty = true; + scheduleAutosave(model); +} + +function attachDragReorder(a, model, ex, block) { + block.draggable = true; + block.title = t("reorder_hint"); + block.addEventListener("dragstart", ev => { + app.dragKey = ex.key; + try { + ev.dataTransfer = ev.dataTransfer || {}; + ev.dataTransfer.setData("text/plain", ex.key); + } catch (_) {} + }); + block.addEventListener("dragend", () => { + app.dragKey = null; + for (const b of $$(".ex-block.drag-over")) b.classList.remove("drag-over"); + }); + block.addEventListener("dragover", ev => { + ev.preventDefault(); + block.classList.add("drag-over"); + }); + block.addEventListener("dragleave", () => block.classList.remove("drag-over")); + block.addEventListener("drop", ev => { + ev.preventDefault(); + block.classList.remove("drag-over"); + reorderExercise(a, model, app.dragKey, ex.key); + app.dragKey = null; + }); +} + +async function resetAllSets(a, model) { + try { + const sess = await api("/api/sessions/" + model.id + "/autofill", + { method: "POST" }); + app.models[a.id] = makeModel(a, sess); + rebuildCard(a); + toast(t("reset_all_done"), "ok"); + } catch (e) { + flashError(e); + } +} + +function saveIndicator(a) { + const s = el("span", "save-ind", t("saved")); + app.saveInd[a.id] = s; + return s; +} + +const SAVE_TXT = { + saved: "saved", + saving: "saving", + error: "error", +}; + +function setSaveState(aid, st) { + const s = app.saveInd[aid]; + if (!s) return; + s.textContent = SAVE_TXT[st] ? t(SAVE_TXT[st]) : ""; + s.className = "save-ind is-" + st; +} + +/* --------------------------------------------------------------------------- + Uebungs-Block + Saetze + ------------------------------------------------------------------------- */ + +function buildExBlock(a, model, ex, removable) { + const block = el("section", "ex-block"); + model.exBlocks[ex.key] = block; + model.exByKey[ex.key] = ex; + + const head = el("div", "ex-head"); + const infoBtn = el("button", "icon-btn", "?"); + infoBtn.type = "button"; + infoBtn.setAttribute("aria-label", t("info_aria") + ex.name); + infoBtn.addEventListener("click", () => openInfo(ex)); + head.appendChild(infoBtn); + head.appendChild(el("span", "ex-name", ex.name)); + const hint = exHint(a, ex); + if (hint && app.settings && app.settings.showTips !== false) { + head.appendChild(el("span", "ex-hint", hint)); + } + + const actions = el("div", "ex-actions"); + if (removable) { + const rm = el("button", "btn btn-small ex-remove", "Entfernen"); + rm.type = "button"; + rm.setAttribute("aria-label", t("remove_exercise_aria")); + rm.addEventListener("click", () => removeExercise(a, model, ex.key)); + actions.appendChild(rm); + } + const add = el("button", "btn btn-small ex-add", t("add_set")); + add.type = "button"; + add.addEventListener("click", () => addSet(a, model, ex.key)); + actions.appendChild(add); + head.appendChild(actions); + block.appendChild(head); + + block.appendChild(el("div", "set-rows")); + renderExRows(a, model, ex); + attachDragReorder(a, model, ex, block); + return block; +} + +function exHint(a, ex) { + const parts = []; + const s = ex.sets; + if (s.list) parts.push(s.list.length + " " + t("set_count")); + else if (s.count) parts.push(s.count + " " + t("set_count")); + if (ex.target) parts.push(ex.target + " " + a.repsLabel); + if (ex.startKg !== undefined && ex.startKg !== "") { + parts.push(t("start_hint") + " " + ex.startKg + " " + weightUnitLabel(a)); + } + return parts.join(" · "); +} + +function renderExRows(a, model, ex) { + const block = model.exBlocks[ex.key]; + const rows = $(".set-rows", block); + rows.innerHTML = ""; + const sets = model.exercises[ex.key] || (model.exercises[ex.key] = []); + model.exChecks[ex.key] = []; + sets.forEach((entry, i) => rows.appendChild(buildSetRow(a, model, ex, entry, i))); +} + +function buildSetRow(a, model, ex, entry, i) { + const row = el("div", "set-row" + (entry.done ? " is-done" : "")); + + const chk = el("button", "set-check" + (entry.done ? " is-done" : ""), ""); + chk.type = "button"; + chk.setAttribute("aria-pressed", entry.done ? "true" : "false"); + chk.setAttribute("aria-label", t("set_done_aria", { n: i + 1 })); + chk.appendChild(el("span", "set-check-label", "Set")); + chk.appendChild(el("span", "set-check-num", String(i + 1))); + chk.appendChild(el("span", "set-check-box", entry.done ? "✓" : "")); + chk.addEventListener("click", () => toggleSetDone(a, model, ex.key, i, chk)); + row.appendChild(chk); + model.exChecks[ex.key].push(chk); + + const wBox = makeFieldBox(t("weight"), weightUnitLabel(a), entry.weight || "", v => { + entry.weight = v; + model.dirtyEx.add(ex.key); + scheduleAutosave(model); + }); + const rBox = makeFieldBox(t("reps"), a.repsLabel, entry.reps || "", v => { + entry.reps = v; + model.dirtyEx.add(ex.key); + scheduleAutosave(model); + }); + row.appendChild(wBox); + row.appendChild(rBox); + entry.wInput = wBox.querySelector("input"); + entry.rInput = rBox.querySelector("input"); + + const reset = el("button", "btn btn-small", t("reset_set")); + reset.type = "button"; + reset.setAttribute("aria-label", t("reset_set_title")); + reset.addEventListener("click", () => resetSet(a, model, ex.key, i, entry)); + row.appendChild(reset); + + const remove = el("button", "icon-btn", "×"); + remove.type = "button"; + remove.setAttribute("aria-label", t("remove_set_aria")); + remove.addEventListener("click", () => removeSet(a, model, ex.key, i)); + row.appendChild(remove); + + return row; +} + +function makeFieldBox(label, unit, value, onChange, inputmode) { + const box = el("label", "field-box"); + box.setAttribute("aria-label", label + " in " + unit); + const input = el("input", "field-input"); + input.type = "text"; + input.inputmode = inputmode || "decimal"; + input.autocomplete = "off"; + input.spellcheck = false; + input.value = value || ""; + input.addEventListener("input", () => onChange(input.value.trim())); + box.appendChild(input); + if (unit) box.appendChild(el("span", "field-unit", unit)); + return box; +} + +/* Set-Kette: Set i aktivieren schaltet 1..i ein, deaktivieren rueckwaerts. + Gewicht/Reps werden durch den Klick nie veraendert. */ + +function toggleSetDone(a, model, exKey, i, chk) { + const sets = model.exercises[exKey]; + if (!sets[i].done) { + for (let k = 0; k <= i; k++) sets[k].done = 1; + } else { + for (let k = i; k < sets.length; k++) sets[k].done = 0; + } + const checks = model.exChecks[exKey]; + for (let k = 0; k < checks.length; k++) { + const on = !!sets[k].done; + checks[k].classList.toggle("is-done", on); + checks[k].setAttribute("aria-pressed", on ? "true" : "false"); + const box = $(".set-check-box", checks[k]); + if (box) box.textContent = on ? "✓" : ""; + checks[k].parentElement.classList.toggle("is-done", on); + } + model.dirtyEx.add(exKey); + scheduleAutosave(model); +} + +function addSet(a, model, exKey) { + model.exercises[exKey].push({ id: null, weight: "", reps: "", done: 0 }); + renderExRows(a, model, model.exByKey[exKey]); + model.dirtyEx.add(exKey); + scheduleAutosave(model); +} + +function removeSet(a, model, exKey, i) { + const sets = model.exercises[exKey]; + sets.splice(i, 1); + renderExRows(a, model, model.exByKey[exKey]); + model.dirtyEx.add(exKey); + scheduleAutosave(model); +} + +async function resetSet(a, model, exKey, i, entry) { + const url = "/api/reset-values?exercise=" + encodeURIComponent(exKey) + "&set=" + i; + try { + const r = await api(url); + if (!r.weight) { + toast(t("no_prev_measure", { n: i + 1 })); + return; + } + entry.weight = r.weight; + entry.reps = r.reps || ""; + entry.wInput.value = entry.weight; + entry.rInput.value = entry.reps; + model.dirtyEx.add(exKey); + scheduleAutosave(model); + } catch (e) { + flashError(e); + } +} + +/* --------------------------------------------------------------------------- + Autosave: nur schmutzige Uebungen / Metadaten gehen raus. + Das DOM wird dabei nie angefasst - keine Fokus-Verluste. + ------------------------------------------------------------------------- */ + +function scheduleAutosave(model) { + clearTimeout(app.autosaveTimer[model.activity]); + app.autosaveTimer[model.activity] = setTimeout( + () => flushAutosave(model.activity), 700); + setSaveState(model.activity, "saving"); +} + +async function flushAutosave(activity) { + if (app.flushBusy) { app.flushQueuedActivity = activity; return; } + app.flushBusy = true; + try { + const model = app.models[activity]; + if (!model) return; + let ok = true; + if (model.metaDirty) { + const meta = { note: model.note, fields: model.fields }; + try { + await api("/api/sessions/" + model.id, jsonOpts("PATCH", { meta })); + model.metaDirty = false; + } catch (_) { ok = false; } + } + for (const exKey of Array.from(model.dirtyEx)) { + // Entfernte Uebungen werden mit leerem Array uebertragen -> Server + // loescht ihre Eintraege. Andere Uebungen bleiben unberuehrt. + const sets = (model.exercises[exKey] || []).map((e, i) => ({ + exercise: exKey, sort: i, weight: e.weight || "", reps: e.reps || "", + done: e.done ? 1 : 0, + })); + try { + await api("/api/sessions/" + model.id, jsonOpts("PATCH", { sets })); + model.dirtyEx.delete(exKey); + } catch (_) { ok = false; } + } + setSaveState(activity, ok ? "saved" : "error"); + } finally { + app.flushBusy = false; + if (app.flushQueuedActivity) { + const queued = app.flushQueuedActivity; + app.flushQueuedActivity = null; + flushAutosave(queued); + } + } +} + +async function finishSession(a, model) { + setSaveState(a.id, "saving"); + try { + await flushAutosave(a.id); + if (model.dirtyEx.size || model.metaDirty) { + toast(t("save_failed"), "error"); + setSaveState(a.id, "error"); + return; + } + await api("/api/sessions/" + model.id, + jsonOpts("PATCH", { meta: { status: "done", end_time: app.meta.now } })); + } catch (e) { + flashError(e); + setSaveState(a.id, "error"); + return; + } + delete app.models[a.id]; + rebuildCard(a); + toast(t("session_saved"), "ok"); +} + +async function discardSession(a, model) { + try { + await api("/api/sessions/" + model.id, { method: "DELETE" }); + } catch (e) { + flashError(e); + return; + } + delete app.models[a.id]; + rebuildCard(a); + toast(t("session_discarded")); +} + +/* --------------------------------------------------------------------------- + Bestaetigungs-Knopf (2-Schritt: klicken, dann kurz t("sure")) + ------------------------------------------------------------------------- */ + +function confirmify(btn, onConfirm, msg) { + btn.addEventListener("click", () => { + if (btn.dataset.armed === "1") { + btn.dataset.armed = "0"; + onConfirm(); + return; + } + btn.dataset.armed = "1"; + const old = btn.textContent; + btn.textContent = msg; + btn.classList.add("is-armed"); + setTimeout(() => { + if (btn.dataset.armed === "1") { + btn.dataset.armed = "0"; + btn.textContent = old; + btn.classList.remove("is-armed"); + } + }, 3000); + }); +} + +/* --------------------------------------------------------------------------- + Verlauf + ------------------------------------------------------------------------- */ + +let histMode = "list"; +let weekStart = null; + +async function loadHistory() { + const res = await api("/api/sessions?status=done"); + renderHistory(res); +} + +function renderHistory(sessions) { + const view = $("#view-history"); + view.innerHTML = ""; + if (!sessions.length && histMode === "list") { + view.appendChild(el("div", "empty-box", t("no_history"))); + return; + } + const nav = el("div", "day-picker"); + for (const [mode, label] of [["list", t("list_view")], ["week", t("week")]]) { + const b = el("button", "day-btn" + (histMode === mode ? " is-active" : ""), label); + b.type = "button"; + b.addEventListener("click", () => { histMode = mode; renderHistory(sessions); }); + nav.appendChild(b); + } + view.appendChild(nav); + if (histMode === "week") { + renderWeekView(view, sessions); + } else { + const byDate = {}; + for (const s of sessions) (byDate[s.date] = byDate[s.date] || []).push(s); + const dates = Object.keys(byDate).sort().reverse(); + for (const d of dates) { + const group = el("section", "hist-group"); + group.appendChild(el("h3", "hist-date", fmtDateLong(d))); + for (const s of byDate[d]) group.appendChild(buildHistCard(s)); + view.appendChild(group); + } + if (!sessions.length) view.appendChild(el("div", "empty-box", t("no_history"))); + } +} + +function mondayOf(iso) { + const p = iso.split("-"); + const dt = new Date(+p[0], +p[1] - 1, +p[2]); + const dow = (dt.getDay() + 6) % 7; + dt.setDate(dt.getDate() - dow); + return isoFromDate(dt); +} + +function isoFromDate(dt) { + return dt.getFullYear() + "-" + + String(dt.getMonth() + 1).padStart(2, "0") + "-" + + String(dt.getDate()).padStart(2, "0"); +} + +function addDaysIso(iso, n) { + const p = iso.split("-"); + return isoFromDate(new Date(+p[0], +p[1] - 1, +p[2] + n)); +} + +function renderWeekView(view, sessions) { + if (!weekStart) weekStart = mondayOf(app.meta.today); + const byDate = {}; + for (const s of sessions) (byDate[s.date] = byDate[s.date] || []).push(s); + + const picker = el("div", "data-row"); + const prev = el("button", "btn btn-small", t("prev_week")); + prev.type = "button"; + prev.addEventListener("click", () => { weekStart = addDaysIso(weekStart, -7); renderHistory(sessions); }); + picker.appendChild(prev); + picker.appendChild(el("span", "muted", t("week_of") + " " + fmtDate(weekStart))); + const next = el("button", "btn btn-small", t("next_week")); + next.type = "button"; + next.addEventListener("click", () => { weekStart = addDaysIso(weekStart, 7); renderHistory(sessions); }); + picker.appendChild(next); + view.appendChild(picker); + + const grid = el("div", "week-grid"); + for (let i = 0; i < 7; i++) { + const d = addDaysIso(weekStart, i); + const cell = el("div", "week-day"); + const head = el("div", "week-day-head", WD[i] + " " + fmtDate(d)); + if (d === app.meta.today) head.classList.add("is-today"); + cell.appendChild(head); + const daySessions = byDate[d] || []; + for (const s of daySessions) { + const a = cfgActivity(s.activity); + const item = el("div", "week-session"); + const title = (a ? a.name : s.activity) + (s.day ? " · " + s.day : ""); + item.appendChild(el("span", "week-session-title", title)); + item.appendChild(el("span", "muted", timeRange(s))); + cell.appendChild(item); + } + if (!daySessions.length) { + cell.appendChild(el("div", "muted", t("no_sessions_week"))); + } + grid.appendChild(cell); + } + view.appendChild(grid); +} + +function buildHistCard(s) { + const card = el("article", "hist-card"); + const a = cfgActivity(s.activity); + + const head = el("div", "hist-head"); + head.appendChild(el("span", "hist-when", timeRange(s))); + const label = s.day + ? (a ? a.name : s.activity) + " · " + s.day + : (a ? a.name : s.activity); + head.appendChild(el("span", "hist-activity", label)); + const actions = el("div", "actions"); + const edit = el("button", "btn btn-small", t("edit")); + edit.type = "button"; + edit.addEventListener("click", () => buildHistEdit(card, s)); + actions.appendChild(edit); + const del = el("button", "btn btn-danger-ghost btn-small", t("delete")); + confirmify(del, async () => { + try { + await api("/api/sessions/" + s.id, { method: "DELETE" }); + loadHistory(); + toast(t("session_deleted")); + } catch (e) { flashError(e); } + }, t("sure")); + actions.appendChild(del); + head.appendChild(actions); + card.appendChild(head); + + if (s.note) card.appendChild(el("div", "hist-note", t("note_prefix") + s.note)); + + if (a && a.type === "cardio") { + card.appendChild(renderCardioSummary(s, a)); + } else { + card.appendChild(renderSetSummary(s, a)); + } + return card; +} + +function timeRange(s) { + if (s.start_time && s.end_time) return s.start_time + t("to_time") + s.end_time; + if (s.start_time) return t("from_time") + s.start_time; + return t("time_unknown"); +} + +function renderSetSummary(s, a) { + const ul = el("ul", "hist-entries"); + const unit = weightUnitLabel(a); + const repsUnit = a ? a.repsLabel : "reps"; + const byEx = {}; + for (const e of s.entries || []) (byEx[e.exercise] = byEx[e.exercise] || []).push(e); + for (const exKey of Object.keys(byEx)) { + const li = el("li", "hist-entry"); + li.appendChild(el("span", "ex-name", exKey + ": ")); + const setsWrap = el("span", "hist-sets"); + const sorted = byEx[exKey].sort((x, y) => x.sort - y.sort); + sorted.forEach((e, idx) => { + const w = e.weight || t("empty_label"); + const r = e.reps || t("empty_label"); + const span = el("span", "hist-set"); + span.appendChild(el("span", e.done ? "set-done-box" : "set-undone-box", + e.done ? "✓" : "□")); + span.appendChild(document.createTextNode( + w + " " + unit + " × " + r + " " + repsUnit)); + setsWrap.appendChild(span); + if (idx < sorted.length - 1) { + setsWrap.appendChild(document.createTextNode(" · ")); + } + }); + li.appendChild(setsWrap); + ul.appendChild(li); + } + return ul; +} + +function renderCardioSummary(s, a) { + const ul = el("ul", "hist-entries"); + const li = el("li", "hist-entry"); + const parts = []; + for (const t of a.fieldTemplates || []) { + const v = (s.fields || {})[t.key]; + if (v !== undefined && v !== null && String(v).trim() !== "") { + parts.push(t.label + " " + v + " " + t.unit); + } + } + for (const k of Object.keys(s.fields || {})) { + const known = (a.fieldTemplates || []).some(t => t.key === k); + if (!known && String(s.fields[k]).trim() !== "") parts.push(k + " " + s.fields[k]); + } + li.appendChild(el("span", "hist-sets", parts.join(" · ") || t("no_values"))); + ul.appendChild(li); + return ul; +} + +/* Inline-Editor: bleibt komplett im Verlauf, blutet nicht in "Heute" ab. */ + +function buildHistEdit(card, s) { + const a = cfgActivity(s.activity); + const data = { + date: s.date, + start_time: s.start_time || "", + end_time: s.end_time || "", + note: s.note || "", + fields: Object.assign({}, s.fields || {}), + sets: (s.entries || []).map(e => ({ + exercise: e.exercise, sort: e.sort, weight: e.weight || "", reps: e.reps || "", + done: e.done ? 1 : 0, + })), + }; + + const form = el("form", "hist-edit"); + form.addEventListener("submit", ev => { + ev.preventDefault(); + saveHistEdit(a, s, data, card); + }); + + const grid = el("div", "hist-edit-grid"); + grid.appendChild(editField(t("date"), "date", data, "date")); + grid.appendChild(editField(t("start"), "time", data, "start_time")); + grid.appendChild(editField(t("end"), "time", data, "end_time")); + const noteLabel = el("label", "", t("note_label")); + const noteInput = el("input", ""); + noteInput.type = "text"; + noteInput.value = data.note; + noteInput.addEventListener("input", () => { data.note = noteInput.value.trim(); }); + noteLabel.appendChild(noteInput); + grid.appendChild(noteLabel); + form.appendChild(grid); + + if (a && a.type === "cardio") { + const wrap = el("div", "edit-rows"); + for (const t of a.fieldTemplates || []) { + const im = t.kind === "number" ? "decimal" : "text"; + wrap.appendChild(makeFieldBox(t.label, t.unit, data.fields[t.key] || "", v => { + data.fields[t.key] = v; + }, im)); + } + form.appendChild(wrap); + } else { + const byEx = {}; + for (const set of data.sets) (byEx[set.exercise] = byEx[set.exercise] || []).push(set); + for (const exKey of Object.keys(byEx)) { + const box = el("div", "edit-ex"); + box.appendChild(el("div", "ex-name", exKey)); + const inner = el("div", "edit-rows"); + for (const set of byEx[exKey]) { + const row = el("div", "edit-row"); + const doneBox = el("input", "edit-done"); + doneBox.type = "checkbox"; + doneBox.checked = !!set.done; + doneBox.setAttribute("aria-label", t("set_done_aria", { n: set.sort + 1 })); + doneBox.addEventListener("change", () => { set.done = doneBox.checked ? 1 : 0; }); + row.appendChild(doneBox); + row.appendChild(el("span", "muted", t("set_n", { n: set.sort + 1 }))); + row.appendChild(makeFieldBox(t("weight"), weightUnitLabel(a), set.weight, v => { + set.weight = v; + })); + row.appendChild(makeFieldBox(t("reps"), a ? a.repsLabel : "reps", set.reps, v => { + set.reps = v; + })); + inner.appendChild(row); + } + box.appendChild(inner); + form.appendChild(box); + } + } + + const actions = el("div", "edit-actions"); + const save = el("button", "btn btn-primary", t("save")); + save.type = "submit"; + actions.appendChild(save); + const cancel = el("button", "btn", t("cancel")); + cancel.type = "button"; + cancel.addEventListener("click", () => { + card.innerHTML = ""; + card.appendChild(buildHistCard(s)); + }); + actions.appendChild(cancel); + form.appendChild(actions); + + card.innerHTML = ""; + card.appendChild(form); +} + +function editField(label, type, data, key) { + const l = el("label", "", label); + const input = el("input", ""); + input.type = type; + input.value = data[key] || ""; + input.addEventListener("input", () => { data[key] = input.value; }); + l.appendChild(input); + return l; +} + +async function saveHistEdit(a, s, data, card) { + const meta = { + date: data.date, + start_time: data.start_time, + end_time: data.end_time, + note: data.note, + }; + if (a && a.type === "cardio") meta.fields = data.fields; + try { + await api("/api/sessions/" + s.id, jsonOpts("PATCH", { meta, sets: data.sets })); + } catch (e) { + flashError(e); + return; + } + toast(t("session_updated"), "ok"); + loadHistory(); +} + +/* --------------------------------------------------------------------------- + Statistik + ------------------------------------------------------------------------- */ + +async function loadStats() { + const view = $("#view-stats"); + view.innerHTML = ""; + const optgroups = []; + for (const a of app.cfg.activities) { + if (a.type !== "strength" || !a.cycle) continue; + for (const d of a.cycle.days) { + const g = el("optgroup"); + g.label = a.name + " · " + d.title; + for (const ex of d.exercises) { + const opt = el("option", "", ex.name); + opt.value = ex.key; + g.appendChild(opt); + } + optgroups.push(g); + } + } + if (!optgroups.length) { + view.appendChild(el("div", "empty-box", t("no_strength"))); + return; + } + const pick = el("div", "stats-pick"); + pick.appendChild(el("span", "field-label", t("choose_exercise"))); + const sel = el("select", "stats-select"); + for (const g of optgroups) sel.appendChild(g); + sel.addEventListener("change", () => renderStats(view, sel.value)); + pick.appendChild(sel); + view.appendChild(pick); + sel.value = optgroups[0].children[0].value; + renderStats(view, sel.value); +} + +async function renderStats(view, exKey) { + const old = $(".stats-table-box", view); + if (old) old.remove(); + const box = el("div", "stats-table-box"); + view.appendChild(box); + let data; + try { + data = await api("/api/stats?exercise=" + encodeURIComponent(exKey)); + } catch (e) { + box.appendChild(el("div", "empty-box", e.message)); + return; + } + if (!data.length) { + box.appendChild(el("div", "empty-box", t("no_stats"))); + return; + } + const maxW = Math.max(...data.map(d => d.weight)); + const table = el("table", "stats-table"); + const thead = el("thead"); + const hr = el("tr"); + for (const col of [["date", t("col_date")], ["best", t("col_best")], + ["reps", t("col_reps")], ["diff", t("col_diff")]]) { + const th = el("th", (col[0] === "best" || col[0] === "diff") ? "num" : "", col[1]); + hr.appendChild(th); + } + thead.appendChild(hr); + table.appendChild(thead); + + const tbody = el("tbody"); + for (const d of data) { + const tr = el("tr"); + tr.appendChild(el("td", "", fmtDate(d.date))); + const cell = el("div", "stats-bar-cell"); + const bar = el("span", "stats-bar"); + bar.style.width = Math.round((d.weight / maxW) * 100) + "%"; + cell.appendChild(bar); + cell.appendChild(el("span", "", fmtKg(d.weight) + " kg")); + const wTd = el("td", "num"); + wTd.appendChild(cell); + tr.appendChild(wTd); + tr.appendChild(el("td", "num", d.reps || "-")); + const delta = d.delta; + const sign = delta > 0 ? "+" : ""; + const cls = delta > 0 ? "delta-pos" : (delta < 0 ? "delta-neg" : "delta-zero"); + tr.appendChild(el("td", "num " + cls, sign + fmtKg(delta) + " kg")); + tbody.appendChild(tr); + } + table.appendChild(tbody); + box.appendChild(table); +} + +/* --------------------------------------------------------------------------- + Daten: Export / Import + ------------------------------------------------------------------------- */ + +function renderDataView() { + const view = $("#view-data"); + view.innerHTML = ""; + + const exp = el("section", "data-section"); + exp.appendChild(el("h3", "", t("export"))); + const eb = el("div", "data-body"); + eb.appendChild(el("p", "", "Der komplette Stand (auch in Arbeit befindliche Sessions) als JSON oder CSV. CSV-Spalten: session_id, activity, day, date, start_time, end_time, note, status, kind, exercise, set, weight, reps, done sowie die Cardio-Felder aus der config.")); + const row1 = el("div", "data-row"); + const a1 = el("a", "btn", t("export_json")); + a1.href = "/api/export?format=json"; + a1.setAttribute("download", ""); + row1.appendChild(a1); + const a2 = el("a", "btn", t("export_csv")); + a2.href = "/api/export?format=csv"; + a2.setAttribute("download", ""); + row1.appendChild(a2); + eb.appendChild(row1); + exp.appendChild(eb); + view.appendChild(exp); + + const imp = el("section", "data-section"); + imp.appendChild(el("h3", "", t("import"))); + const ib = el("div", "data-body"); + ib.appendChild(el("p", "", t("import_desc"))); + + const row2 = el("div", "data-row"); + const fjson = el("input", "file-input"); + fjson.type = "file"; + fjson.accept = ".json,application/json"; + row2.appendChild(fjson); + const bjson = el("button", "btn", t("import_json")); + bjson.type = "button"; + const resJson = el("div", "import-result"); + bjson.addEventListener("click", () => doImport(fjson, "trainingsplan-export-v2", resJson)); + row2.appendChild(bjson); + ib.appendChild(row2); + ib.appendChild(resJson); + + const row3 = el("div", "data-row"); + const fcsv = el("input", "file-input"); + fcsv.type = "file"; + fcsv.accept = ".csv,text/csv"; + row3.appendChild(fcsv); + const bcsv = el("button", "btn", t("import_csv")); + bcsv.type = "button"; + const resCsv = el("div", "import-result"); + bcsv.addEventListener("click", () => doImport(fcsv, "csv", resCsv)); + row3.appendChild(bcsv); + ib.appendChild(row3); + ib.appendChild(resCsv); + + imp.appendChild(ib); + view.appendChild(imp); +} + +async function doImport(input, format, res) { + const file = input.files && input.files[0]; + if (!file) { + res.className = "import-result is-error"; + res.textContent = t("file_first"); + return; + } + try { + const text = await file.text(); + const r = await api("/api/import", jsonOpts("POST", { format, data: text })); + res.className = "import-result"; + res.textContent = t("import_ok", { c: r.created, u: r.updated }); + } catch (e) { + res.className = "import-result is-error"; + res.textContent = t("import_fail", { m: e.message }); + } +} + +/* --------------------------------------------------------------------------- + Overlay (Uebungs-Erklaerung), Tabs, Start + ------------------------------------------------------------------------- */ + +function openInfo(ex) { + const root = $("#overlay-root"); + root.innerHTML = ""; + root.hidden = false; + const box = el("div", "overlay-box"); + box.appendChild(el("h3", "", ex.name)); + if (ex.info) box.appendChild(el("p", "overlay-info", ex.info)); + for (const tag of ex.tags || []) box.appendChild(el("span", "tag-chip", tag)); + const close = el("button", "btn btn-primary btn-block", t("close")); + close.type = "button"; + close.addEventListener("click", closeOverlay); + box.appendChild(close); + root.appendChild(box); +} + +function closeOverlay() { + const root = $("#overlay-root"); + root.hidden = true; + root.innerHTML = ""; +} + +function setupOverlay() { + $("#overlay-root").addEventListener("click", ev => { + if (ev.target.id === "overlay-root") closeOverlay(); + }); +} + +function switchView(name) { + for (const t of $$(".tab")) { + t.classList.toggle("is-active", t.dataset.view === name); + } + for (const v of $$(".view")) { + v.classList.toggle("is-active", v.id === "view-" + name); + } + if (name === "history") loadHistory(); + if (name === "stats") loadStats(); + if (name === "data") renderDataView(); + if (name === "settings") renderSettingsView(); +} + +/* --------------------------------------------------------------------------- + Setup-Wizard + Anmeldung + ------------------------------------------------------------------------- */ + +const wizard = { mode: "setup", step: 0, multi: null, lang: null, + theme: null, oled: false, accent: null, userCreated: false }; + +function renderSetupWizard(mode) { + wizard.mode = mode; + wizard.step = 0; // Sprache zuerst + wizard.multi = null; + wizard.lang = (app.settings && app.settings.language) || "de"; + wizard.theme = (app.settings && app.settings.theme) || "dark"; + wizard.oled = !!(app.settings && app.settings.oled); + wizard.accent = (app.settings && app.settings.accent) || "white"; + wizard.userCreated = false; + document.body.classList.add("no-auth"); + $("#setup-gate").hidden = false; + renderWizardStep(); +} + +function setupOption(title, desc, active) { + const b = el("button", "setup-option" + (active ? " is-active" : ""), ""); + b.type = "button"; + b.appendChild(el("strong", "", title)); + b.appendChild(el("span", "setup-option-desc", desc)); + return b; +} + +function wizardToggle(key, label, desc, value, onChange) { + const row = el("label", "toggle-row"); + const cb = el("input", "toggle-input"); + cb.type = "checkbox"; + cb.checked = !!value; + cb.addEventListener("change", () => { + wizard[key] = cb.checked; + if (onChange) onChange(); + }); + const txt = el("span", "toggle-text"); + txt.appendChild(el("strong", "", label)); + if (desc) txt.appendChild(el("span", "muted", desc)); + row.appendChild(cb); + row.appendChild(txt); + return row; +} + +function previewWizardAppearance() { + applySettings(Object.assign({}, app.settings, { + theme: wizard.theme, oled: wizard.oled, accent: wizard.accent, + })); +} + +function wizardStepValid() { + if (wizard.step === 0) return !!wizard.lang; + if (wizard.mode === "setup" && wizard.step === 1) { + if (wizard.multi === null) return false; + return wizard.multi === false || wizard.userCreated; + } + return !!wizard.theme && !!wizard.accent; +} + +function wizardMaxStep() { + return wizard.mode === "setup" ? 2 : 1; +} + +function wizardProgress() { + const count = wizard.mode === "setup" ? 3 : 2; + const wrap = el("div", "wizard-progress"); + for (let i = 0; i < count; i++) { + const cls = i < wizard.step ? " is-done" + : i === wizard.step ? " is-current" : ""; + wrap.appendChild(el("span", "wp-step" + cls, "")); + } + return wrap; +} + +function renderWizardStep() { + const gate = $("#setup-gate"); + gate.innerHTML = ""; + const box = el("div", "auth-box"); + const head = el("div", "auth-head"); + head.appendChild(el("h2", "auth-title", BRAND)); + head.appendChild(wizardProgress()); + box.appendChild(head); + + if (wizard.step === 0) { + // Schritt 0: Sprache (zuerst) + box.appendChild(el("h3", "wizard-title", t("lang_step"))); + if (wizard.mode === "setup") { + box.appendChild(el("p", "auth-sub", t("lang_server"))); + } + const row = el("div", "day-picker"); + for (const [code, label] of [["de", t("german")], ["en", t("english")]]) { + const b = el("button", "day-btn" + (wizard.lang === code ? " is-active" : ""), label); + b.type = "button"; + b.addEventListener("click", () => { + wizard.lang = code; + app.settings = Object.assign({}, app.settings, { language: code }); + renderWizardStep(); + }); + row.appendChild(b); + } + box.appendChild(row); + } else if (wizard.step === 1 && wizard.mode === "setup") { + // Schritt 1: Einzel- oder Mehrbenutzermodus + box.appendChild(el("p", "auth-sub", t("welcome"))); + const grid = el("div", "setup-options"); + const multi = setupOption(t("multi_mode"), t("multi_desc"), wizard.multi === true); + multi.addEventListener("click", () => { + wizard.multi = true; + renderWizardStep(); + }); + grid.appendChild(multi); + const solo = setupOption(t("solo_mode"), t("solo_desc"), wizard.multi === false); + solo.addEventListener("click", () => { + wizard.multi = false; + renderWizardStep(); + }); + grid.appendChild(solo); + box.appendChild(grid); + if (wizard.multi === true) { + const form = el("form", "auth-form"); + form.appendChild(authField(t("username"), "setup-user")); + form.appendChild(authField(t("password"), "setup-pass")); + const err = el("div", "auth-error", ""); + err.hidden = true; + const submit = el("button", "btn btn-primary btn-block", t("first_user")); + submit.type = "submit"; + form.appendChild(submit); + form.appendChild(err); + form.addEventListener("submit", ev => { + ev.preventDefault(); + wizardRegister($("#setup-user").value, $("#setup-pass").value, err); + }); + box.appendChild(form); + } + } else { + // Letzter Schritt: Darstellung (Theme + OLED + Akzent) + box.appendChild(el("h3", "wizard-title", t("appearance"))); + if (wizard.mode === "setup") { + box.appendChild(el("p", "auth-sub", t("color_server"))); + } + box.appendChild(el("div", "picker-label", t("theme_label"))); + const themeRow = el("div", "day-picker"); + for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) { + const b = el("button", "day-btn" + (wizard.theme === key ? " is-active" : ""), label); + b.type = "button"; + b.addEventListener("click", () => { + wizard.theme = key; + previewWizardAppearance(); + renderWizardStep(); + }); + themeRow.appendChild(b); + } + box.appendChild(themeRow); + box.appendChild(wizardToggle("oled", t("oled_toggle"), t("oled_desc"), wizard.oled, + previewWizardAppearance)); + box.appendChild(el("div", "picker-label", t("accent_label"))); + const accentRow = el("div", "accent-row"); + for (const [key, color] of Object.entries(ACCENTS)) { + const sw = el("button", "accent-swatch" + (wizard.accent === key ? " is-active" : ""), ""); + sw.type = "button"; + sw.style.background = color; + sw.setAttribute("aria-label", t("accent_aria", { k: key })); + sw.addEventListener("click", () => { + wizard.accent = key; + previewWizardAppearance(); + renderWizardStep(); + }); + accentRow.appendChild(sw); + } + box.appendChild(accentRow); + } + + // Fusszeile: Zurueck links, Weiter/Abschliessen rechts + const nav = el("div", "wizard-nav"); + if (wizard.step > 0) { + const back = el("button", "btn", t("back")); + back.type = "button"; + back.addEventListener("click", () => { + wizard.step--; + renderWizardStep(); + }); + nav.appendChild(back); + } + const right = el("div", "wizard-nav-right"); + if (wizard.step < wizardMaxStep()) { + const next = el("button", "btn btn-primary", t("next")); + next.type = "button"; + next.disabled = !wizardStepValid(); + next.addEventListener("click", () => { + wizard.step++; + renderWizardStep(); + }); + right.appendChild(next); + } else { + const finish = el("button", "btn btn-primary", t("finish_setup")); + finish.type = "button"; + finish.disabled = !wizardStepValid(); + finish.addEventListener("click", wizardFinish); + right.appendChild(finish); + } + nav.appendChild(right); + box.appendChild(nav); + + gate.appendChild(box); +} + +async function wizardFinish() { + const patch = { + theme: wizard.theme, oled: wizard.oled, accent: wizard.accent, + language: wizard.lang, setupDone: true, + }; + if (app.me && app.me.username) patch.onboarded = true; + try { + app.settings = await api("/api/settings", jsonOpts("PUT", patch)); + } catch (e) { + flashError(e); + return; + } + await afterAuth(); +} + +async function wizardRegister(username, password, errEl) { + try { + const r = await api("/api/register", jsonOpts("POST", { + username, password, + })); + localStorage.setItem("tp_token", r.token); + app.me = { username: r.username, is_owner: true }; + wizard.userCreated = true; + renderWizardStep(); + } catch (e) { + if (e.status === 403) { + // Ein Benutzer existiert inzwischen (z.B. zweiter Tab): neu laden + if (errEl) errEl.textContent = eMsg(e); + setTimeout(() => afterAuth(), 600); + } else if (errEl) { + errEl.hidden = false; + errEl.textContent = eMsg(e); + } else { + flashError(e); + } + } +} + +/* --------------------------------------------------------------------------- + Anmeldung (Auth-Gate) + ------------------------------------------------------------------------- */ + +function renderAuth() { + document.body.classList.add("no-auth"); + const gate = $("#auth-gate"); + gate.hidden = false; + gate.innerHTML = ""; + + const box = el("div", "auth-box"); + box.appendChild(el("h2", "auth-title", BRAND)); + box.appendChild(el("p", "auth-sub", t("login_sub"))); + + const form = el("form", "auth-form"); + form.appendChild(authField(t("username"), "auth-user")); + form.appendChild(authField(t("password"), "auth-pass")); + const err = el("div", "auth-error", ""); + err.hidden = true; + const submit = el("button", "btn btn-primary btn-block", t("login")); + submit.type = "submit"; + form.appendChild(submit); + form.appendChild(err); + form.addEventListener("submit", ev => { + ev.preventDefault(); + doAuth("login", $("#auth-user").value, $("#auth-pass").value, err); + }); + box.appendChild(form); + gate.appendChild(box); +} + +function authField(label, id) { + const l = el("label", "auth-field", label); + const input = el("input", ""); + input.id = id; + input.type = id.includes("pass") ? "password" : "text"; + input.autocomplete = id.includes("pass") ? "current-password" : "username"; + l.appendChild(input); + return l; +} + +async function doAuth(mode, username, password, errEl) { + try { + const r = await api("/api/" + mode, jsonOpts("POST", { + username, password, + })); + localStorage.setItem("tp_token", r.token); + toast(mode === "register" ? t("user_created_toast") : t("logged_in"), "ok"); + await afterAuth(); + } catch (e) { + if (errEl) { + errEl.hidden = false; + errEl.textContent = eMsg(e); + } else { + flashError(e); + } + } +} + +function setupLogout() { + const btn = $("#logout-btn"); + btn.addEventListener("click", async () => { + try { + await api("/api/logout", { method: "POST" }); + } catch (_) {} + localStorage.removeItem("tp_token"); + app.me = null; + app.models = {}; + $("#view-today").innerHTML = ""; + $("#today-label").textContent = ""; + app.meta = await api("/api/meta"); + app.settings = { language: app.meta ? app.meta.language : "de" }; + document.body.classList.add("no-auth"); + $("#setup-gate").hidden = true; + $("#setup-gate").innerHTML = ""; + renderAuth(); + }); +} + +function setUserChip() { + const chip = $("#user-chip"); + const btn = $("#logout-btn"); + if (app.me && app.me.username) { + chip.hidden = false; + chip.textContent = app.me.username; + btn.hidden = false; + } +} + +/* --------------------------------------------------------------------------- + Einstellungen + ------------------------------------------------------------------------- */ + +function prefersDark() { + try { + return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); + } catch (_) { + return false; + } +} + +function applySettings(s) { + const root = document.documentElement; + let t = s.theme || "dark"; + if (t === "system") t = prefersDark() ? "dark" : "light"; + if (s.oled && t === "dark") t = "oled"; + root.dataset.theme = t; + root.dataset.accent = s.accent || "white"; + $("#view-today").classList.toggle("no-tips", s.showTips === false); +} + +function setupThemeWatch() { + try { + if (!window.matchMedia) return; + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + if (mq.addEventListener) { + mq.addEventListener("change", () => { + if (app.settings) applySettings(app.settings); + }); + } + } catch (_) {} +} + +function weightUnitLabel(a) { + const s = app.settings && app.settings.weightUnit; + return (s && (s === "lb")) ? "lb" : (a && a.weightUnit) || "kg"; +} + +async function setSetting(key, value) { + app.settings[key] = value; + try { + app.settings = await api("/api/settings", jsonOpts("PUT", { [key]: value })); + } catch (e) { + flashError(e); + return; + } + applySettings(app.settings); + renderSettingsView(); + toast(t("setting_saved"), "accent"); +} + +function renderSettingsView() { + const view = $("#view-settings"); + view.innerHTML = ""; + + const sec1 = el("section", "data-section"); + sec1.appendChild(el("h3", "", t("appearance"))); + const b1 = el("div", "data-body"); + + b1.appendChild(el("div", "picker-label", t("language_label"))); + const langRow = el("div", "day-picker"); + for (const [code, label] of [["de", t("german")], ["en", t("english")]]) { + const b = el("button", "day-btn" + (app.settings.language === code ? " is-active" : ""), label); + b.type = "button"; + b.addEventListener("click", () => setSetting("language", code)); + langRow.appendChild(b); + } + b1.appendChild(langRow); + + b1.appendChild(el("div", "picker-label", t("theme_label"))); + const themeRow = el("div", "day-picker"); + for (const [key, label] of [["dark", t("dark")], ["system", t("system")], ["light", t("light")]]) { + const b = el("button", "day-btn" + (app.settings.theme === key ? " is-active" : ""), label); + b.type = "button"; + b.addEventListener("click", () => setSetting("theme", key)); + themeRow.appendChild(b); + } + b1.appendChild(themeRow); + b1.appendChild(toggleRow("oled", t("oled_toggle"), t("oled_desc"))); + + b1.appendChild(el("div", "picker-label", t("accent_label"))); + const accentRow = el("div", "accent-row"); + for (const [key, color] of Object.entries(ACCENTS)) { + const sw = el("button", "accent-swatch" + (app.settings.accent === key ? " is-active" : ""), ""); + sw.type = "button"; + sw.style.background = color; + sw.setAttribute("aria-label", t("accent_aria", { k: key })); + sw.addEventListener("click", () => setSetting("accent", key)); + accentRow.appendChild(sw); + } + b1.appendChild(accentRow); + sec1.appendChild(b1); + view.appendChild(sec1); + + const sec2 = el("section", "data-section"); + sec2.appendChild(el("h3", "", t("behavior"))); + const b2 = el("div", "data-body"); + b2.appendChild(toggleRow("autoReset", t("last_workout"), + t("last_workout_desc"))); + b2.appendChild(toggleRow("planPrefill", t("plan_prefill"), + t("plan_prefill_desc"))); + b2.appendChild(toggleRow("showTips", t("show_tips"), + t("show_tips_desc"))); + b2.appendChild(settingsSelect("weightUnit", t("weight_unit"), + [["kg", "kg"], ["lb", "lb"]])); + b2.appendChild(settingsSelect("startTab", t("start_tab"), + [["today", t("tab_today")], ["history", t("tab_history")], + ["stats", t("tab_stats")], ["data", t("tab_data")]])); + const resetDef = el("button", "btn btn-small", t("reset_defaults")); + resetDef.type = "button"; + resetDef.addEventListener("click", async () => { + app.settings = await api("/api/settings", + jsonOpts("PUT", { theme: "dark", oled: false, accent: "white", + autoReset: false, planPrefill: false, showTips: true, + language: "de", weightUnit: "kg", startTab: "today" })); + applySettings(app.settings); + renderSettingsView(); + toast(t("defaults_reset"), "accent"); + }); + const resetRow = el("div", "data-row"); + resetRow.appendChild(resetDef); + b2.appendChild(resetRow); + sec2.appendChild(b2); + view.appendChild(sec2); + + if (app.me && app.me.username) { + view.appendChild(renderAccountSection()); + } + if (app.me && (!app.me.username || app.me.is_owner)) { + view.appendChild(renderUsersSection()); + } +} + +function settingsSelect(key, label, options) { + const wrap = el("label", "settings-select-row", label); + const sel = el("select", "stats-select"); + for (const [value, text] of options) { + const opt = el("option", "", text); + opt.value = value; + sel.appendChild(opt); + } + sel.value = app.settings[key] || options[0][0]; + sel.addEventListener("change", () => setSetting(key, sel.value)); + wrap.appendChild(sel); + return wrap; +} + +function renderAccountSection() { + const sec = el("section", "data-section"); + sec.appendChild(el("h3", "", t("account"))); + const body = el("div", "data-body"); + body.appendChild(el("p", "", t("account_desc", { u: app.me.username }))); + const row = el("div", "data-row"); + const cur = el("input", "file-input"); + cur.type = "password"; + cur.placeholder = t("current_pw"); + const neu = el("input", "file-input"); + neu.type = "password"; + neu.placeholder = t("new_pw"); + const btn = el("button", "btn", t("change_pw")); + btn.type = "button"; + btn.addEventListener("click", async () => { + try { + await api("/api/password", jsonOpts("POST", { + current_password: cur.value, new_password: neu.value, + })); + cur.value = ""; + neu.value = ""; + toast(t("pw_changed"), "ok"); + } catch (e) { flashError(e); } + }); + row.appendChild(cur); + row.appendChild(neu); + row.appendChild(btn); + body.appendChild(row); + sec.appendChild(body); + return sec; +} + +function renderUsersSection() { + if (!app.me.username) return renderSetupUsersSection(); + return renderOwnerUsersSection(); +} + +function renderSetupUsersSection() { + const sec = el("section", "data-section"); + sec.appendChild(el("h3", "", t("setup_users"))); + const body = el("div", "data-body"); + body.appendChild(el("p", "", t("users_solo_desc"))); + const row = el("div", "data-row"); + const name = el("input", "file-input"); + name.type = "text"; + name.placeholder = t("username"); + const pw = el("input", "file-input"); + pw.type = "password"; + pw.placeholder = t("password"); + const add = el("button", "btn", t("first_user")); + add.type = "button"; +add.addEventListener("click", async () => { + try { + const r = await api("/api/register", jsonOpts("POST", { + username: name.value, password: pw.value, + })); + localStorage.setItem("tp_token", r.token); + toast(t("user_created", { u: r.username }), "ok"); + await afterAuth(); + } catch (e) { + if (e.status === 403) { + toast(eMsg(e), "error"); + setTimeout(() => afterAuth(), 800); + } else { + flashError(e); + } + } + }); + row.appendChild(name); + row.appendChild(pw); + row.appendChild(add); + body.appendChild(row); + sec.appendChild(body); + return sec; +} + +function renderOwnerUsersSection() { + const sec = el("section", "data-section"); + sec.appendChild(el("h3", "", t("users"))); + const body = el("div", "data-body"); + body.appendChild(el("p", "", t("users_desc"))); + const list = el("ul", "user-list"); + body.appendChild(list); + const reload = async () => { + list.innerHTML = ""; + let users; + try { + users = await api("/api/users"); + } catch (e) { flashError(e); return; } + const others = users.filter(u => u.id !== 1); // Administrator nicht auflisten + if (!others.length) { + const li = el("li", "user-row", t("only_user")); + li.classList.add("muted-only"); + list.appendChild(li); + } + for (const u of others) { + const li = el("li", "user-row", u.username); + const del = el("button", "btn btn-small btn-danger-ghost", t("delete")); + confirmify(del, async () => { + try { + await api("/api/users/" + u.id, { method: "DELETE" }); + } catch (e) { flashError(e); return; } + toast(t("user_deleted"), "ok"); + reload(); + }, t("sure")); + li.appendChild(del); + const pwBtn = el("button", "btn btn-small", t("change_pw")); + pwBtn.type = "button"; + pwBtn.addEventListener("click", () => { + // Kein versehentlicher Abbruch: solange das Formular offen ist, + // passiert beim erneuten Klick nichts; abbrechen geht ueber die + // eigene Schaltflaeche. + if (li.querySelector(".pw-reset")) return; + const f = el("form", "pw-reset"); + const inp = el("input", "file-input"); + inp.type = "password"; + inp.placeholder = t("new_pw"); + const ok = el("button", "btn btn-small", t("apply")); + ok.type = "submit"; + const cancel = el("button", "btn btn-small", t("cancel_change")); + cancel.type = "button"; + f.appendChild(inp); + f.appendChild(ok); + f.appendChild(cancel); + f.addEventListener("submit", ev => { + ev.preventDefault(); + api("/api/users/" + u.id + "/password", + jsonOpts("PUT", { password: inp.value })) + .then(() => { + toast(t("pw_set", { u: u.username }), "ok"); + f.remove(); + }) + .catch(e => flashError(e)); + }); + cancel.addEventListener("click", () => f.remove()); + li.appendChild(f); + }); + li.appendChild(pwBtn); + list.appendChild(li); + } + }; + reload(); + const row = el("div", "data-row"); + const name = el("input", "file-input"); + name.type = "text"; + name.placeholder = t("username"); + const pw = el("input", "file-input"); + pw.type = "password"; + pw.placeholder = t("password"); + const add = el("button", "btn", t("create")); + add.type = "button"; + add.addEventListener("click", async () => { + try { + const r = await api("/api/users", jsonOpts("POST", { + username: name.value, password: pw.value, + })); + toast(t("user_created", { u: r.username }), "ok"); + name.value = ""; + pw.value = ""; + reload(); + } catch (e) { flashError(e); } + }); + row.appendChild(name); + row.appendChild(pw); + row.appendChild(add); + body.appendChild(row); + sec.appendChild(body); + return sec; +} + +function toggleRow(key, label, desc) { + const row = el("label", "toggle-row"); + const cb = el("input", "toggle-input"); + cb.type = "checkbox"; + cb.checked = !!app.settings[key]; + cb.addEventListener("change", () => setSetting(key, cb.checked)); + const txt = el("span", "toggle-text"); + txt.appendChild(el("strong", "", label)); + if (desc) txt.appendChild(el("span", "muted", desc)); + row.appendChild(cb); + row.appendChild(txt); + return row; +} + +let uiSetupDone = false; + +function setupUi() { + if (uiSetupDone) return; + uiSetupDone = true; + for (const tab of $$(".tab")) { + tab.addEventListener("click", () => switchView(tab.dataset.view)); + } + setupOverlay(); + setupLogout(); + setupThemeWatch(); +} + +async function init() { + app.meta = await api("/api/meta"); + try { + app.me = await api("/api/me"); + } catch (e) { + if (e.status === 401) { + app.settings = { language: app.meta.language || "de" }; + renderAuth(); + return; + } + throw e; + } + setupUi(); + await loadAppData(); +} + +async function loadAppData() { + app.cfg = await api("/api/config"); + app.settings = await api("/api/settings"); + applySettings(app.settings); + $("#today-label").textContent = t("today_header") + fmtDateLong(app.meta.today); + setUserChip(); + await loadToday(); + const startTab = app.settings.startTab || "today"; + if (startTab !== "today") switchView(startTab); + if (app.meta.users === 0 && !app.settings.setupDone) { + renderSetupWizard("setup"); + } else if (app.me.username && !app.settings.onboarded) { + renderSetupWizard("onboarding"); + } +} + +/* Nach An-/Abmeldung in-place neu laden - kein location.reload, damit die + Anmeldung auch in eingebetteten/sandboxed Umgebungen sofort greift. */ +async function afterAuth() { + document.body.classList.remove("no-auth"); + for (const id of ["#auth-gate", "#setup-gate"]) { + $(id).hidden = true; + $(id).innerHTML = ""; + } + app.models = {}; + try { + await init(); + } catch (e) { + flashError(e); + } +} + +init().catch(e => { + $("#view-today").appendChild(el("div", "empty-box", + t("app_load_fail", { m: e.message }))); +}).finally(() => { + document.body.classList.remove("boot"); +}); \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..5bbbde4 --- /dev/null +++ b/config.json @@ -0,0 +1,219 @@ +{ + "profile": { + "name": "", + "weightUnit": "kg", + "repsLabel": "reps" + }, + "activities": [ + { + "id": "gym", + "name": "Gym (Kraft)", + "type": "strength", + "active": true, + "displayOrder": 1, + "cycle": { + "name": "Push/Pull/Legs", + "days": [ + { + "key": "Push", + "title": "Push", + "exercises": [ + { + "name": "Bankdrücken KH (je Hand)", + "sets": 4, + "reps": "8-10", + "startKg": 18, + "tags": ["brust", "kh"], + "info": "Technogym Chest Press (Selection 900/700). Hanteldrücken mit Kurzhanteln: auf dem Polster sitzen, Schulterblätter zusammen, Gewicht langsam nach vorn-oben drücken, oben kurz halten, kontrolliert senken. Macht Brust + hintere Schulter." + }, + { + "name": "Schrägbank KH (je Hand)", + "sets": 3, + "reps": "10-12", + "startKg": 14, + "tags": ["brust", "kh"], + "info": "Wie Bankdrücken, aber Bank auf ~30-45 Grad geneigt. Ziel: obere Brust + vordere Schulter. Langsam senken bis Brust und zwingend kontrolliert drücken." + }, + { + "name": "Pectoral/Butterfly", + "sets": 3, + "reps": "12-15", + "startKg": 20, + "tags": ["brust", "maschine"], + "info": "Technogym Pectoral (Selection 900) oder Butterfly-Maschine. Unterarme auf den Polstern, Griffe langsam zusammenführen, in der Endstellung kurz anspannen, kontrolliert zurück." + }, + { + "name": "Seitheben (je Hand)", + "sets": 3, + "reps": "12-15", + "startKg": 8, + "tags": ["schulter", "kh"], + "info": "Technogym Delts Machine (falls Maschine) oder Kurzhanteln. Seitliches Anheben bis Schulterhöhe, Ellenbogen leicht gebeugt, oben kurz halten, langsam senken. Trifft seitliche Schulter (Delts)." + }, + { + "name": "Schulterpresse Maschine", + "sets": 3, + "reps": "10-12", + "startKg": 25, + "tags": ["schulter", "maschine"], + "info": "Technogym Shoulder Press (Selection 900/700). Sitzen, hintere Schulter gegen Rückenpolster, Gewicht über Kopf drücken, oben nicht komplett durchstrecken." + }, + { + "name": "Kabel-Trizeps-Pushdown", + "sets": 3, + "reps": "12-15", + "startKg": 25, + "tags": ["trizeps", "kabel"], + "info": "Am Kabelturm: Griff auf Brusthöhe, Ellenbogen dicht am Körper lassen, Unterarm nach unten strecken, am Ende anspannen, kontrolliert zurück. Dein Favorit am Kabelturm." + }, + { + "name": "Trizeps-Strecken (French Press Maschine)", + "sets": 3, + "reps": "12-15", + "startKg": 15, + "tags": ["trizeps", "maschine"], + "info": "Technogym Arm Extension (Selection 900) - Oberarme liegen auf dem Polster, Gewicht über Kopf strecken, nur die Unterarme bewegen sich. Alternativ: Kabel-Überkopf." + }, + { + "name": "Kabel-Curls (Bizeps)", + "sets": 3, + "reps": "12-15", + "startKg": 15, + "tags": ["bizeps", "kabel"], + "info": "Bizeps am Kabelturm (Favorit): Stange oder Seil auf niedriger Höhe, Ellenbogen dicht am Körper, Unterarme nach oben rollen bis der Bizeps angespannt ist, oben kurz halten, langsam zurück. Macht den kompletten Bizeps, gut kontrollierbar." + } + ] + }, + { + "key": "Pull", + "title": "Pull", + "exercises": [ + { + "name": "Latzug / Klimmzug-Maschine", + "sets": 4, + "reps": "8-10", + "startKg": 55, + "tags": ["ruecken", "maschine"], + "info": "Technogym Lat Machine (Selection 900/700) oder Vertical Traction. Griff auf Brust- bzw. Nackenhöhe ziehen, Schulterblätter zuerst nach unten, Ellbogen zum Rumpf." + }, + { + "name": "Rudern (Low Row)", + "sets": 4, + "reps": "10", + "startKg": 50, + "tags": ["ruecken", "maschine"], + "info": "Technogym Low Row (Selection 900/700). Griff zum Bauch ziehen, Rumpf stabil, Schulterblätter hinten zusammenziehen, langsam zurück. Trifft breiten Rücken + Rhomboiden." + }, + { + "name": "Face Pulls", + "sets": 3, + "reps": "12-15", + "startKg": 20, + "tags": ["schulter", "kabel"], + "info": "Am Kabelturm, Band oder Seil auf Augenhöhe: zu dir zum Gesicht ziehen, Ellbogen auf Schulterhöhe ausbreiten. Trifft hintere Schulter + hält die Schulterblätter gesund." + }, + { + "name": "Kabel-Curls hoch (Bizeps)", + "sets": 3, + "reps": "12-15", + "startKg": 14, + "tags": ["bizeps", "kabel"], + "info": "Kabelturm-Curls mit höherem Griff: leichte Neigung, Zug von oben-vorne. Alternativ oder als zweite Bizeps-Übung, anderer Winkel als der normale Kabel-Curl." + }, + { + "name": "Reverse Fly (Rückwärtsheben)", + "sets": 3, + "reps": "12-15", + "startKg": 20, + "tags": ["schulter", "maschine"], + "info": "Technogym Reverse Fly (Selection 900) oder Dual Pectoral/Reverse Fly (700). Nach vorn lehnen bzw. sitzen, Arme nach hinten-aussen hebend, Schulterblätter zusammen. Trifft hintere Delts + Rhomboiden, gute Haltung." + } + ] + }, + { + "key": "Legs", + "title": "Legs", + "exercises": [ + { + "name": "Beinpresse", + "sets": 4, + "reps": "8-10", + "startKg": 120, + "tags": ["beine", "maschine"], + "info": "Technogym Leg Press (Selection 900/700). Sitzen, Füße schulterbreit auf der Platte, nicht komplett durchstrecken (Knie nie ganz gestreckt), kontrolliert bis 90 Grad senken, drücken. Schwerste Übung des Tages." + }, + { + "name": "Beinbeuger (Leg Curl)", + "sets": 3, + "reps": "10-12", + "startKg": 50, + "tags": ["beine", "maschine"], + "info": "Technogym Leg Curl (Selection 900/700), sitzend oder liegend. Knie beugen, Fersen zum Gesäß ziehen, oben kurz anspannen. Trifft Beinrückseite (Hamstrings)." + }, + { + "name": "Beinstrecker (Leg Extension)", + "sets": 3, + "reps": "12-15", + "startKg": 55, + "tags": ["beine", "maschine"], + "info": "Technogym Leg Extension (Selection 900/700) - dein Kind. Beine unter dem Polster, Knie voll strecken und oben kurz halten, langsam senken. Trifft die Oberschenkelvorderseite (Quadrizeps). Dein 55er-Gewicht passt hier perfekt." + }, + { + "name": "Wadenheben stehend", + "sets": 3, + "reps": "15-20", + "startKg": 65, + "tags": ["waden", "maschine"], + "info": "Technogym Standing Calf (Selection 900). Auf Zehenspitzen hochdrücken, oben kurz halten, bis die Wade gedehnt ist senken. Trifft die Wadenmuskulatur." + }, + { + "name": "Glute Press / Hip Thrust", + "sets": 3, + "reps": "10-12", + "startKg": 60, + "tags": ["po", "maschine"], + "info": "Technogym Glute Press oder Hip Thrust (Selection 900). Schulterblätter auf das Polster, Hüfte nach oben drücken, oben anspannen. Trifft Gesäß + hintere Oberschenkel." + }, + { + "name": "(opz.) Bauch-Finisher (Ab Crunch)", + "sets": 2, + "reps": "10-15", + "startKg": 0, + "tags": ["bauch", "maschine"], + "info": "Technogym Abdominal Crunch (Selection 900/700). Optional zum Abschluss: Rumpf runden, Bauch anspannen, nicht am Nacken ziehen. Nur wenn es sich gut anfühlt." + } + ] + } + ] + } + }, + { + "id": "run", + "name": "Laufen", + "type": "cardio", + "active": true, + "displayOrder": 2, + "fieldTemplates": [ + { "key": "distanceKm", "label": "Distanz", "unit": "km", "kind": "number", "step": 0.1 }, + { "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number", "step": 1 }, + { "key": "pacePerKm", "label": "Pace", "unit": "min/km", "kind": "pace", "step": null }, + { "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "step": 1, "optional": true }, + { "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "step": 1, "optional": true } + ] + }, + { + "id": "ride", + "name": "Radfahren", + "type": "cardio", + "active": true, + "displayOrder": 3, + "fieldTemplates": [ + { "key": "distanceKm", "label": "Distanz", "unit": "km", "kind": "number", "step": 0.1 }, + { "key": "minutes", "label": "Dauer", "unit": "min", "kind": "number", "step": 1 }, + { "key": "avgSpeed", "label": "Ø Tempo", "unit": "km/h", "kind": "number", "step": 0.1 }, + { "key": "heartRate", "label": "Puls Ø", "unit": "bpm", "kind": "number", "step": 1, "optional": true }, + { "key": "elevation", "label": "Höhenmeter", "unit": "m", "kind": "number", "step": 1, "optional": true } + ] + } + ] +} \ No newline at end of file diff --git a/demo-data.json b/demo-data.json new file mode 100644 index 0000000..a00ae36 --- /dev/null +++ b/demo-data.json @@ -0,0 +1,2767 @@ +{ + "format": "trainingsplan-export-v2", + "exported_at": "2026-10-01T00:00:00", + "config": { + "profile": { + "name": "", + "weightUnit": "kg", + "repsLabel": "reps" + }, + "activities": [ + { + "id": "gym", + "name": "Gym (Kraft)", + "type": "strength", + "active": true, + "displayOrder": 1, + "cycle": { + "name": "Push/Pull/Legs", + "days": [ + { + "key": "Push", + "title": "Push", + "exercises": [ + { + "name": "Bankdrücken KH (je Hand)", + "sets": 4, + "reps": "8-10", + "startKg": 18, + "tags": [ + "brust", + "kh" + ], + "info": "Technogym Chest Press (Selection 900/700). Hanteldrücken mit Kurzhanteln: auf dem Polster sitzen, Schulterblätter zusammen, Gewicht langsam nach vorn-oben drücken, oben kurz halten, kontrolliert senken. Macht Brust + hintere Schulter." + }, + { + "name": "Schrägbank KH (je Hand)", + "sets": 3, + "reps": "10-12", + "startKg": 14, + "tags": [ + "brust", + "kh" + ], + "info": "Wie Bankdrücken, aber Bank auf ~30-45 Grad geneigt. Ziel: obere Brust + vordere Schulter. Langsam senken bis Brust und zwingend kontrolliert drücken." + }, + { + "name": "Pectoral/Butterfly", + "sets": 3, + "reps": "12-15", + "startKg": 20, + "tags": [ + "brust", + "maschine" + ], + "info": "Technogym Pectoral (Selection 900) oder Butterfly-Maschine. Unterarme auf den Polstern, Griffe langsam zusammenführen, in der Endstellung kurz anspannen, kontrolliert zurück." + }, + { + "name": "Seitheben (je Hand)", + "sets": 3, + "reps": "12-15", + "startKg": 8, + "tags": [ + "schulter", + "kh" + ], + "info": "Technogym Delts Machine (falls Maschine) oder Kurzhanteln. Seitliches Anheben bis Schulterhöhe, Ellenbogen leicht gebeugt, oben kurz halten, langsam senken. Trifft seitliche Schulter (Delts)." + }, + { + "name": "Schulterpresse Maschine", + "sets": 3, + "reps": "10-12", + "startKg": 25, + "tags": [ + "schulter", + "maschine" + ], + "info": "Technogym Shoulder Press (Selection 900/700). Sitzen, hintere Schulter gegen Rückenpolster, Gewicht über Kopf drücken, oben nicht komplett durchstrecken." + }, + { + "name": "Kabel-Trizeps-Pushdown", + "sets": 3, + "reps": "12-15", + "startKg": 25, + "tags": [ + "trizeps", + "kabel" + ], + "info": "Am Kabelturm: Griff auf Brusthöhe, Ellenbogen dicht am Körper lassen, Unterarm nach unten strecken, am Ende anspannen, kontrolliert zurück. Dein Favorit am Kabelturm." + }, + { + "name": "Trizeps-Strecken (French Press Maschine)", + "sets": 3, + "reps": "12-15", + "startKg": 15, + "tags": [ + "trizeps", + "maschine" + ], + "info": "Technogym Arm Extension (Selection 900) - Oberarme liegen auf dem Polster, Gewicht über Kopf strecken, nur die Unterarme bewegen sich. Alternativ: Kabel-Überkopf." + }, + { + "name": "Kabel-Curls (Bizeps)", + "sets": 3, + "reps": "12-15", + "startKg": 15, + "tags": [ + "bizeps", + "kabel" + ], + "info": "Bizeps am Kabelturm (Favorit): Stange oder Seil auf niedriger Höhe, Ellenbogen dicht am Körper, Unterarme nach oben rollen bis der Bizeps angespannt ist, oben kurz halten, langsam zurück. Macht den kompletten Bizeps, gut kontrollierbar." + } + ] + }, + { + "key": "Pull", + "title": "Pull", + "exercises": [ + { + "name": "Latzug / Klimmzug-Maschine", + "sets": 4, + "reps": "8-10", + "startKg": 55, + "tags": [ + "ruecken", + "maschine" + ], + "info": "Technogym Lat Machine (Selection 900/700) oder Vertical Traction. Griff auf Brust- bzw. Nackenhöhe ziehen, Schulterblätter zuerst nach unten, Ellbogen zum Rumpf." + }, + { + "name": "Rudern (Low Row)", + "sets": 4, + "reps": "10", + "startKg": 50, + "tags": [ + "ruecken", + "maschine" + ], + "info": "Technogym Low Row (Selection 900/700). Griff zum Bauch ziehen, Rumpf stabil, Schulterblätter hinten zusammenziehen, langsam zurück. Trifft breiten Rücken + Rhomboiden." + }, + { + "name": "Face Pulls", + "sets": 3, + "reps": "12-15", + "startKg": 20, + "tags": [ + "schulter", + "kabel" + ], + "info": "Am Kabelturm, Band oder Seil auf Augenhöhe: zu dir zum Gesicht ziehen, Ellbogen auf Schulterhöhe ausbreiten. Trifft hintere Schulter + hält die Schulterblätter gesund." + }, + { + "name": "Kabel-Curls hoch (Bizeps)", + "sets": 3, + "reps": "12-15", + "startKg": 14, + "tags": [ + "bizeps", + "kabel" + ], + "info": "Kabelturm-Curls mit höherem Griff: leichte Neigung, Zug von oben-vorne. Alternativ oder als zweite Bizeps-Übung, anderer Winkel als der normale Kabel-Curl." + }, + { + "name": "Reverse Fly (Rückwärtsheben)", + "sets": 3, + "reps": "12-15", + "startKg": 20, + "tags": [ + "schulter", + "maschine" + ], + "info": "Technogym Reverse Fly (Selection 900) oder Dual Pectoral/Reverse Fly (700). Nach vorn lehnen bzw. sitzen, Arme nach hinten-aussen hebend, Schulterblätter zusammen. Trifft hintere Delts + Rhomboiden, gute Haltung." + } + ] + }, + { + "key": "Legs", + "title": "Legs", + "exercises": [ + { + "name": "Beinpresse", + "sets": 4, + "reps": "8-10", + "startKg": 120, + "tags": [ + "beine", + "maschine" + ], + "info": "Technogym Leg Press (Selection 900/700). Sitzen, Füße schulterbreit auf der Platte, nicht komplett durchstrecken (Knie nie ganz gestreckt), kontrolliert bis 90 Grad senken, drücken. Schwerste Übung des Tages." + }, + { + "name": "Beinbeuger (Leg Curl)", + "sets": 3, + "reps": "10-12", + "startKg": 50, + "tags": [ + "beine", + "maschine" + ], + "info": "Technogym Leg Curl (Selection 900/700), sitzend oder liegend. Knie beugen, Fersen zum Gesäß ziehen, oben kurz anspannen. Trifft Beinrückseite (Hamstrings)." + }, + { + "name": "Beinstrecker (Leg Extension)", + "sets": 3, + "reps": "12-15", + "startKg": 55, + "tags": [ + "beine", + "maschine" + ], + "info": "Technogym Leg Extension (Selection 900/700) - dein Kind. Beine unter dem Polster, Knie voll strecken und oben kurz halten, langsam senken. Trifft die Oberschenkelvorderseite (Quadrizeps). Dein 55er-Gewicht passt hier perfekt." + }, + { + "name": "Wadenheben stehend", + "sets": 3, + "reps": "15-20", + "startKg": 65, + "tags": [ + "waden", + "maschine" + ], + "info": "Technogym Standing Calf (Selection 900). Auf Zehenspitzen hochdrücken, oben kurz halten, bis die Wade gedehnt ist senken. Trifft die Wadenmuskulatur." + }, + { + "name": "Glute Press / Hip Thrust", + "sets": 3, + "reps": "10-12", + "startKg": 60, + "tags": [ + "po", + "maschine" + ], + "info": "Technogym Glute Press oder Hip Thrust (Selection 900). Schulterblätter auf das Polster, Hüfte nach oben drücken, oben anspannen. Trifft Gesäß + hintere Oberschenkel." + }, + { + "name": "(opz.) Bauch-Finisher (Ab Crunch)", + "sets": 2, + "reps": "10-15", + "startKg": 0, + "tags": [ + "bauch", + "maschine" + ], + "info": "Technogym Abdominal Crunch (Selection 900/700). Optional zum Abschluss: Rumpf runden, Bauch anspannen, nicht am Nacken ziehen. Nur wenn es sich gut anfühlt." + } + ] + } + ] + } + }, + { + "id": "run", + "name": "Laufen", + "type": "cardio", + "active": true, + "displayOrder": 2, + "fieldTemplates": [ + { + "key": "distanceKm", + "label": "Distanz", + "unit": "km", + "kind": "number", + "step": 0.1 + }, + { + "key": "minutes", + "label": "Dauer", + "unit": "min", + "kind": "number", + "step": 1 + }, + { + "key": "pacePerKm", + "label": "Pace", + "unit": "min/km", + "kind": "pace", + "step": null + }, + { + "key": "heartRate", + "label": "Puls Ø", + "unit": "bpm", + "kind": "number", + "step": 1, + "optional": true + }, + { + "key": "elevation", + "label": "Höhenmeter", + "unit": "m", + "kind": "number", + "step": 1, + "optional": true + } + ] + }, + { + "id": "ride", + "name": "Radfahren", + "type": "cardio", + "active": true, + "displayOrder": 3, + "fieldTemplates": [ + { + "key": "distanceKm", + "label": "Distanz", + "unit": "km", + "kind": "number", + "step": 0.1 + }, + { + "key": "minutes", + "label": "Dauer", + "unit": "min", + "kind": "number", + "step": 1 + }, + { + "key": "avgSpeed", + "label": "Ø Tempo", + "unit": "km/h", + "kind": "number", + "step": 0.1 + }, + { + "key": "heartRate", + "label": "Puls Ø", + "unit": "bpm", + "kind": "number", + "step": 1, + "optional": true + }, + { + "key": "elevation", + "label": "Höhenmeter", + "unit": "m", + "kind": "number", + "step": 1, + "optional": true + } + ] + } + ] + }, + "sessions": [ + { + "id": 1, + "activity": "gym", + "day": "Push", + "date": "2026-08-26", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 0, + "weight": "17.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 1, + "weight": "17.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 2, + "weight": "17.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 3, + "weight": "17.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 0, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 1, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 2, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 0, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 1, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 2, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 0, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 1, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 2, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 0, + "weight": "25", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 1, + "weight": "25", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 2, + "weight": "25", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 0, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 1, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 2, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 2, + "activity": "gym", + "day": "Pull", + "date": "2026-08-27", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 0, + "weight": "55", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 1, + "weight": "55", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 2, + "weight": "55", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 3, + "weight": "55", + "reps": "7", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 0, + "weight": "50", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 1, + "weight": "50", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 2, + "weight": "50", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 3, + "weight": "50", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 0, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 1, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 2, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 0, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 1, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 2, + "weight": "20", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 3, + "activity": "gym", + "day": "Legs", + "date": "2026-08-28", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Legs/Beinpresse", + "sort": 0, + "weight": "120", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 1, + "weight": "120", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 2, + "weight": "120", + "reps": "8", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 3, + "weight": "120", + "reps": "7", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 0, + "weight": "50", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 1, + "weight": "50", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 2, + "weight": "50", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 0, + "weight": "55", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 1, + "weight": "55", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 2, + "weight": "55", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 0, + "weight": "65", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 1, + "weight": "65", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 2, + "weight": "65", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 0, + "weight": "60", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 1, + "weight": "60", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 2, + "weight": "60", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 0, + "weight": "0", + "reps": "12", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 1, + "weight": "0", + "reps": "12", + "done": 1 + } + ] + }, + { + "id": 4, + "activity": "gym", + "day": "Push", + "date": "2026-09-02", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 0, + "weight": "17.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 1, + "weight": "17.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 2, + "weight": "17.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 3, + "weight": "17.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 0, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 1, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 2, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 0, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 1, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 2, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 0, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 1, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 2, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 0, + "weight": "25", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 1, + "weight": "25", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 2, + "weight": "25", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 0, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 1, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 2, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 5, + "activity": "gym", + "day": "Pull", + "date": "2026-09-03", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 0, + "weight": "55", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 1, + "weight": "55", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 2, + "weight": "55", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 3, + "weight": "55", + "reps": "7", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 0, + "weight": "50", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 1, + "weight": "50", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 2, + "weight": "50", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 3, + "weight": "50", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 0, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 1, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 2, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 0, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 1, + "weight": "20", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 2, + "weight": "20", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 6, + "activity": "gym", + "day": "Legs", + "date": "2026-09-04", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Legs/Beinpresse", + "sort": 0, + "weight": "120", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 1, + "weight": "120", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 2, + "weight": "120", + "reps": "8", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 3, + "weight": "120", + "reps": "7", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 0, + "weight": "50", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 1, + "weight": "50", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 2, + "weight": "50", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 0, + "weight": "55", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 1, + "weight": "55", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 2, + "weight": "55", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 0, + "weight": "65", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 1, + "weight": "65", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 2, + "weight": "65", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 0, + "weight": "60", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 1, + "weight": "60", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 2, + "weight": "60", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 0, + "weight": "0", + "reps": "12", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 1, + "weight": "0", + "reps": "12", + "done": 1 + } + ] + }, + { + "id": 7, + "activity": "gym", + "day": "Push", + "date": "2026-09-09", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 0, + "weight": "17.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 1, + "weight": "17.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 2, + "weight": "17.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 3, + "weight": "17.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 0, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 1, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 2, + "weight": "15", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 0, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 1, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 2, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 0, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 1, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 2, + "weight": "7.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 0, + "weight": "27.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 1, + "weight": "27.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 2, + "weight": "27.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 0, + "weight": "27.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 1, + "weight": "27.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 2, + "weight": "27.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 8, + "activity": "gym", + "day": "Pull", + "date": "2026-09-10", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 0, + "weight": "57.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 1, + "weight": "57.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 2, + "weight": "57.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 3, + "weight": "57.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 0, + "weight": "52.5", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 1, + "weight": "52.5", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 2, + "weight": "52.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 3, + "weight": "52.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 0, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 1, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 2, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 0, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 1, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 2, + "weight": "15", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 0, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 1, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 2, + "weight": "22.5", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 9, + "activity": "gym", + "day": "Legs", + "date": "2026-09-11", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Legs/Beinpresse", + "sort": 0, + "weight": "122.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 1, + "weight": "122.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 2, + "weight": "122.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 3, + "weight": "122.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 0, + "weight": "52.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 1, + "weight": "52.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 2, + "weight": "52.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 0, + "weight": "57.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 1, + "weight": "57.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 2, + "weight": "57.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 0, + "weight": "67.5", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 1, + "weight": "67.5", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 2, + "weight": "67.5", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 0, + "weight": "62.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 1, + "weight": "62.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 2, + "weight": "62.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 0, + "weight": "0", + "reps": "12", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 1, + "weight": "0", + "reps": "12", + "done": 1 + } + ] + }, + { + "id": 10, + "activity": "gym", + "day": "Push", + "date": "2026-09-16", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 0, + "weight": "20", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 1, + "weight": "20", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 2, + "weight": "20", + "reps": "8", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 3, + "weight": "20", + "reps": "7", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 0, + "weight": "17.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 1, + "weight": "17.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 2, + "weight": "17.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 0, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 1, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 2, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 0, + "weight": "10", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 1, + "weight": "10", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 2, + "weight": "10", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 0, + "weight": "27.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 1, + "weight": "27.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 2, + "weight": "27.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 0, + "weight": "27.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 1, + "weight": "27.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 2, + "weight": "27.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 0, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 1, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 2, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 0, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 1, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 2, + "weight": "17.5", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 11, + "activity": "gym", + "day": "Pull", + "date": "2026-09-17", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 0, + "weight": "57.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 1, + "weight": "57.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 2, + "weight": "57.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 3, + "weight": "57.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 0, + "weight": "52.5", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 1, + "weight": "52.5", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 2, + "weight": "52.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 3, + "weight": "52.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 0, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 1, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 2, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 0, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 1, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 2, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 0, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 1, + "weight": "22.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 2, + "weight": "22.5", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 12, + "activity": "gym", + "day": "Legs", + "date": "2026-09-18", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Legs/Beinpresse", + "sort": 0, + "weight": "122.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 1, + "weight": "122.5", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 2, + "weight": "122.5", + "reps": "8", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 3, + "weight": "122.5", + "reps": "7", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 0, + "weight": "52.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 1, + "weight": "52.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 2, + "weight": "52.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 0, + "weight": "57.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 1, + "weight": "57.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 2, + "weight": "57.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 0, + "weight": "67.5", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 1, + "weight": "67.5", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 2, + "weight": "67.5", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 0, + "weight": "62.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 1, + "weight": "62.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 2, + "weight": "62.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 0, + "weight": "2.5", + "reps": "12", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 1, + "weight": "2.5", + "reps": "12", + "done": 1 + } + ] + }, + { + "id": 13, + "activity": "gym", + "day": "Push", + "date": "2026-09-23", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 0, + "weight": "20", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 1, + "weight": "20", + "reps": "9", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 2, + "weight": "20", + "reps": "8", + "done": 1 + }, + { + "exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 3, + "weight": "20", + "reps": "7", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 0, + "weight": "17.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 1, + "weight": "17.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schrägbank KH (je Hand)", + "sort": 2, + "weight": "17.5", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 0, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 1, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Pectoral/Butterfly", + "sort": 2, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 0, + "weight": "10", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 1, + "weight": "10", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Seitheben (je Hand)", + "sort": 2, + "weight": "10", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 0, + "weight": "30", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 1, + "weight": "30", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Schulterpresse Maschine", + "sort": 2, + "weight": "30", + "reps": "11", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 0, + "weight": "30", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 1, + "weight": "30", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Trizeps-Pushdown", + "sort": 2, + "weight": "30", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 0, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 1, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Trizeps-Strecken (French Press Maschine)", + "sort": 2, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 0, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 1, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Push/Kabel-Curls (Bizeps)", + "sort": 2, + "weight": "17.5", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 14, + "activity": "gym", + "day": "Pull", + "date": "2026-09-24", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 0, + "weight": "60", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 1, + "weight": "60", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 2, + "weight": "60", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Latzug / Klimmzug-Maschine", + "sort": 3, + "weight": "60", + "reps": "7", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 0, + "weight": "55", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 1, + "weight": "55", + "reps": "10", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 2, + "weight": "55", + "reps": "9", + "done": 1 + }, + { + "exercise": "Pull/Rudern (Low Row)", + "sort": 3, + "weight": "55", + "reps": "8", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 0, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 1, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Face Pulls", + "sort": 2, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 0, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 1, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Kabel-Curls hoch (Bizeps)", + "sort": 2, + "weight": "17.5", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 0, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 1, + "weight": "25", + "reps": "13", + "done": 1 + }, + { + "exercise": "Pull/Reverse Fly (Rückwärtsheben)", + "sort": 2, + "weight": "25", + "reps": "13", + "done": 1 + } + ] + }, + { + "id": 15, + "activity": "gym", + "day": "Legs", + "date": "2026-09-25", + "start_time": "18:00", + "end_time": "19:15", + "note": "", + "fields": {}, + "status": "done", + "entries": [ + { + "exercise": "Legs/Beinpresse", + "sort": 0, + "weight": "125", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 1, + "weight": "125", + "reps": "9", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 2, + "weight": "125", + "reps": "8", + "done": 1 + }, + { + "exercise": "Legs/Beinpresse", + "sort": 3, + "weight": "125", + "reps": "7", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 0, + "weight": "55", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 1, + "weight": "55", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinbeuger (Leg Curl)", + "sort": 2, + "weight": "55", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 0, + "weight": "60", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 1, + "weight": "60", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Beinstrecker (Leg Extension)", + "sort": 2, + "weight": "60", + "reps": "13", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 0, + "weight": "70", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 1, + "weight": "70", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Wadenheben stehend", + "sort": 2, + "weight": "70", + "reps": "17", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 0, + "weight": "65", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 1, + "weight": "65", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/Glute Press / Hip Thrust", + "sort": 2, + "weight": "65", + "reps": "11", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 0, + "weight": "2.5", + "reps": "12", + "done": 1 + }, + { + "exercise": "Legs/(opz.) Bauch-Finisher (Ab Crunch)", + "sort": 1, + "weight": "2.5", + "reps": "12", + "done": 1 + } + ] + }, + { + "id": 16, + "activity": "run", + "day": "", + "date": "2026-08-27", + "start_time": "07:15", + "end_time": "07:55", + "note": "Laufrunde", + "fields": { + "distanceKm": "4.5", + "minutes": "29", + "pacePerKm": "6:24", + "heartRate": "150", + "elevation": "18" + }, + "status": "done", + "entries": [] + }, + { + "id": 17, + "activity": "run", + "day": "", + "date": "2026-09-03", + "start_time": "07:15", + "end_time": "07:55", + "note": "Laufrunde", + "fields": { + "distanceKm": "5.0", + "minutes": "32", + "pacePerKm": "6:15", + "heartRate": "149", + "elevation": "22" + }, + "status": "done", + "entries": [] + }, + { + "id": 18, + "activity": "ride", + "day": "", + "date": "2026-09-05", + "start_time": "09:00", + "end_time": "09:50", + "note": "Ausfahrt", + "fields": { + "distanceKm": "14", + "minutes": "37", + "avgSpeed": "22.8", + "heartRate": "139", + "elevation": "70" + }, + "status": "done", + "entries": [] + }, + { + "id": 19, + "activity": "run", + "day": "", + "date": "2026-09-10", + "start_time": "07:15", + "end_time": "07:55", + "note": "Laufrunde", + "fields": { + "distanceKm": "5.5", + "minutes": "35", + "pacePerKm": "6:06", + "heartRate": "148", + "elevation": "26" + }, + "status": "done", + "entries": [] + }, + { + "id": 20, + "activity": "run", + "day": "", + "date": "2026-09-17", + "start_time": "07:15", + "end_time": "07:55", + "note": "Laufrunde", + "fields": { + "distanceKm": "6.0", + "minutes": "38", + "pacePerKm": "5:57", + "heartRate": "147", + "elevation": "30" + }, + "status": "done", + "entries": [] + }, + { + "id": 21, + "activity": "ride", + "day": "", + "date": "2026-09-19", + "start_time": "09:00", + "end_time": "09:50", + "note": "Ausfahrt", + "fields": { + "distanceKm": "18", + "minutes": "44", + "avgSpeed": "24.4", + "heartRate": "141", + "elevation": "90" + }, + "status": "done", + "entries": [] + }, + { + "id": 22, + "activity": "run", + "day": "", + "date": "2026-09-24", + "start_time": "07:15", + "end_time": "07:55", + "note": "Laufrunde", + "fields": { + "distanceKm": "6.5", + "minutes": "42", + "pacePerKm": "5:48", + "heartRate": "146", + "elevation": "34" + }, + "status": "done", + "entries": [] + } + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8385883 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + trainingsplan: + build: . + image: somantis/trainingsplan:latest + container_name: trainingsplan + restart: unless-stopped + environment: + TRAINING_STATE_DIR: /DATA/AppData/trainingsplan/data + PORT: 8080 + volumes: + # CasaOS: State als echter Ordner im AppData (sichtbar, backupbar) + - /DATA/AppData/trainingsplan/data:/app/data + # optional: config.json als Mount -> Plan aendern ohne Image-Rebuild + # - /DATA/AppData/trainingsplan/config.json:/app/config.json:ro + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/api/meta',timeout=2)"] + interval: 30s + timeout: 3s + retries: 3 \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..40bd273 --- /dev/null +++ b/index.html @@ -0,0 +1,41 @@ + + + + + + REKORD + + + +
+
+ REKORD + +
+ + + +
+ +
+
+
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 0000000..be6b4ec --- /dev/null +++ b/server.py @@ -0,0 +1,1618 @@ +#!/usr/bin/env python3 +""" +Trainingsplan - selbstgehosteter Trainingsverlaufs-Tracker. + +Backend aus der Python-Standardbibliothek (http.server) + SQLite. +Alle fachlichen Einstellungen (Uebungen, Saetze, Reps, Gewichte, Maschinen, +Erklaerungen, Cardio-Felder) liegen in config.json - nicht im Code. + +Mehrbenutzer: +- Solange KEIN Benutzer existiert, laeuft die App im offenen Einzelmodus + (user_id = 0). Der erste Benutzer registriert sich und uebernimmt dabei + vorhandene Daten (Migration). +- Danach ist fuer alle /api/*-Endpunkte (ausser /api/meta, /api/login, + /api/register) ein Bearer-Token noetig. Daten sind strikt pro Benutzer. + +API: + GET / statische Datei index.html + GET /styles.css, /app.js statische Dateien + GET /api/meta {today, now, auth_required, users} + GET /api/config normalisierte Konfiguration + GET /api/settings Einstellungen des Benutzers + PUT /api/settings Teil-Update + GET /api/sessions Filter: activity, status, from, to + POST /api/sessions Session starten {activity, day} + PATCH /api/sessions/ Teil-Update: {meta, sets} + POST /api/sessions//autofill Saetze auf letztes Workout setzen + DELETE /api/sessions/ Session samt Saetzen loeschen + GET /api/workouts abgeschlossene Workouts, uebungen gruppiert + GET /api/reset-values letzte abgeschlossene Messung eines Sets + GET /api/stats?exercise= Steigerung je Termin + GET /api/export?format=json|csv kompletter Stand des Benutzers + POST /api/import {format, data} + POST /api/register erster Benutzer (nur wenn keine existieren) + POST /api/login {username, password} -> {token} + POST /api/logout Token invalidieren + GET /api/me {username, is_owner} + GET /api/users Benutzerliste (nur Owner) + POST /api/users Benutzer anlegen (nur Owner) + DELETE /api/users/ Benutzer + Daten loeschen (nur Owner) + +Umgebung: + PORT Port (Default 8080) + TRAINING_STATE_DIR Verzeichnis fuer state.db (Default ./data) +""" + +import csv +import hashlib +import hmac +import io +import json +import os +import secrets +import sqlite3 +import sys +from datetime import date, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +BASE = os.path.dirname(os.path.abspath(__file__)) +STATE_DIR = os.environ.get("TRAINING_STATE_DIR", os.path.join(BASE, "data")) +DB = os.path.join(STATE_DIR, "state.db") +CONFIG_FILE = os.path.join(BASE, "config.json") + +STATIC_FILES = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/styles.css": ("styles.css", "text/css; charset=utf-8"), + "/app.js": ("app.js", "application/javascript; charset=utf-8"), +} + +MAX_BODY = 8 * 1024 * 1024 + +OPEN_USER = 0 # user_id ohne Anmeldung (offener Modus / Alt-Daten) +PBKDF2_ITERATIONS = 200_000 + +CONFIG = {} + +DEFAULT_SETTINGS = { + "theme": "dark", # dark | system | light + "oled": False, # reines Schwarz fuer den dunklen Modus + "accent": "white", # Schluessel aus der Palette (Frontend-CSS) + "autoReset": False, # letztes Workout der Kategorie beim Session-Start laden + "planPrefill": False, # Plan-Gewichte (startKg) beim Session-Start vorbefuellen + "showTips": True, # Uebungs-Hinweise ("4 Sätze · 8-10 reps · Start 18 kg") + "language": "de", # de | en + "setupDone": False, # Ersteinrichtungs-Wizard abgeschlossen (serverweit) + "onboarded": False, # persoenlicher Wizard (Sprache/Akzent) abgeschlossen + "weightUnit": "kg", # kg | lb (Anzeige-Einheit fuer Gewichte) + "startTab": "today", # Standard-Ansicht beim Start +} + + +# --------------------------------------------------------------------------- +# Konfiguration: normalisieren, damit das Frontend nie ueber rohe Struktur +# stolpert und der Nicht-Programmierer nur config.json pflegt. +# --------------------------------------------------------------------------- + +def _num(v, default=None): + try: + return int(v) + except (TypeError, ValueError): + return default + + +def _norm_templates(tpls): + """Cardio-Feld-Templates: {key, label, unit, kind, step, optional}.""" + if isinstance(tpls, dict): + tpls = tpls.get("session") or tpls.get("set") or [] + out = [] + for t in tpls or []: + if not isinstance(t, dict): + continue + key = t.get("key") + if not key: + continue + out.append({ + "key": key, + "label": t.get("label") or key, + "unit": t.get("unit") or "", + "kind": t.get("kind") or "number", + "step": t.get("step"), + "optional": bool(t.get("optional")), + }) + return out + + +def _norm_sets(ex): + """sets als Zahl (uniform) oder als Liste individueller Saetze.""" + start = ex.get("startKg") or "" + reps = ex.get("reps") or "" + raw = ex.get("sets") + if isinstance(raw, int) or (isinstance(raw, str) and raw.strip().isdigit()): + count = max(1, _num(raw, 1)) + return {"count": count, "weight": start, "reps": reps} + if isinstance(raw, list): + sets = [] + for s in raw[:100]: + if not isinstance(s, dict): + continue + sets.append({ + "weight": s.get("weight") if s.get("weight") is not None else start, + "reps": s.get("reps") if s.get("reps") is not None else reps, + }) + return {"list": sets} + return {"count": 1, "weight": start, "reps": reps} + + +def _norm_cycle(cycle): + if not isinstance(cycle, dict): + return {"name": "Trainingsplan", "days": []} + days = [] + for d in cycle.get("days") or []: + if not isinstance(d, dict): + continue + title = d.get("title") or d.get("key") or "Tag" + key = d.get("key") or title + exercises = [] + for x in d.get("exercises") or []: + if not isinstance(x, dict) or not x.get("name"): + continue + name = x["name"] + exercises.append({ + "name": name, + "key": f"{key}/{name}", + "sets": _norm_sets(x), + "target": x.get("reps") or "", + "startKg": x.get("startKg") or "", + "info": x.get("info") or "", + "tags": list(x.get("tags") or []), + }) + days.append({"key": key, "title": title, "exercises": exercises}) + return {"name": cycle.get("name") or "Trainingsplan", "days": days} + + +def normalize_config(raw): + cfg = raw if isinstance(raw, dict) else {} + profile = cfg.get("profile") or {} + weight_unit = profile.get("weightUnit") or "kg" + reps_label = profile.get("repsLabel") or "reps" + out = { + "profile": { + "name": profile.get("name") or "", + "weightUnit": weight_unit, + "repsLabel": reps_label, + }, + "activities": [], + } + seen = set() + for a in cfg.get("activities") or []: + if not isinstance(a, dict): + continue + aid = str(a.get("id") or "").strip() + if not aid or aid in seen: + continue + seen.add(aid) + atype = a.get("type") or "strength" + item = { + "id": aid, + "name": a.get("name") or aid, + "type": atype, + "active": bool(a.get("active", True)), + "displayOrder": int(a.get("displayOrder") or 100), + "weightUnit": a.get("weightUnit") or weight_unit, + "repsLabel": a.get("repsLabel") or reps_label, + } + if atype == "cardio": + item["fieldTemplates"] = _norm_templates(a.get("fieldTemplates")) + else: + item["cycle"] = _norm_cycle(a.get("cycle")) + out["activities"].append(item) + out["activities"].sort(key=lambda x: x["displayOrder"]) + return out + + +def load_config(): + global CONFIG + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, encoding="utf-8") as f: + CONFIG = normalize_config(json.load(f)) + return + except Exception as exc: + print(f"WARNUNG: config.json konnte nicht gelesen werden ({exc}). " + f"Server laeuft mit leerer Konfiguration.", file=sys.stderr) + CONFIG = normalize_config({}) + + +def cardio_keys(): + """Alle Cardio-Feld-Schluessel aus der Konfiguration (fuer CSV-Spalten).""" + keys = [] + for a in CONFIG.get("activities", []): + if a.get("type") == "cardio": + for t in a.get("fieldTemplates", []): + if t.get("key") and t["key"] not in keys: + keys.append(t["key"]) + return keys + + +def find_activity_config(aid): + for a in CONFIG.get("activities", []): + if a.get("id") == aid: + return a + return None + + +def find_exercise_config(exercise): + """Konfig-Uebung zu einem Uebungsschluessel (Tag/Name) oder None.""" + for a in CONFIG.get("activities", []): + if a.get("type") != "strength": + continue + for d in a.get("cycle", {}).get("days", []): + for x in d.get("exercises", []): + if x.get("key") == exercise: + return x + return None + + +# --------------------------------------------------------------------------- +# SQLite +# --------------------------------------------------------------------------- + +def db(): + con = sqlite3.connect(DB, timeout=30) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys=ON") + con.execute("PRAGMA busy_timeout=5000") + return con + + +def init_db(): + os.makedirs(STATE_DIR, exist_ok=True) + con = db() + con.execute("PRAGMA journal_mode=WAL") + con.executescript(""" + CREATE TABLE IF NOT EXISTS sessions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity TEXT NOT NULL, + day TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL, + start_time TEXT NOT NULL DEFAULT '', + end_time TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + fields TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT '', + user_id INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS entries( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + exercise TEXT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + weight TEXT NOT NULL DEFAULT '', + reps TEXT NOT NULL DEFAULT '', + done INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_entries_session ON entries(session_id); + CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); + CREATE TABLE IF NOT EXISTS settings( + user_id INTEGER NOT NULL DEFAULT 0, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(user_id, key) + ); + CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS tokens( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT '' + ); + """) + # Migration: aeltere DBs ohne user_id bekommen den offenen Modus + cols = [r[1] for r in con.execute("PRAGMA table_info(sessions)").fetchall()] + if "user_id" not in cols: + con.execute("ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0") + cols = [r[1] for r in con.execute("PRAGMA table_info(settings)").fetchall()] + if "user_id" not in cols: + # alte settings-Tabelle (key, value) -> neue (user_id, key, value) + con.execute("ALTER TABLE settings RENAME TO settings_legacy") + con.execute("""CREATE TABLE settings( + user_id INTEGER NOT NULL DEFAULT 0, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(user_id, key))""") + con.execute("INSERT INTO settings(user_id,key,value) " + "SELECT 0, key, value FROM settings_legacy") + con.execute("DROP TABLE settings_legacy") + con.commit() + con.close() + + +def _clean_str(v): + return str(v).strip() if v is not None else "" + + +def _parse_num(v): + """'18' | '18,5' | '18.5' -> float, sonst None (beide Dezimaltrennzeichen).""" + s = _clean_str(v).replace(",", ".") + if not s: + return None + try: + return float(s) + except ValueError: + return None + + +def _user_clause(user_id, alias=""): + """WHERE-Teil fuer Benutzerscoping. OPEN_USER=0 (offener Modus/Alt-Daten).""" + col = f"{alias}user_id" + return f"{col} = ?", [user_id] + + +def user_count(): + con = db() + row = con.execute("SELECT COUNT(*) AS c FROM users").fetchone() + con.close() + return row["c"] + + +def entries_for_session(sid, con=None): + own = con is None + if own: + con = db() + try: + rows = con.execute( + "SELECT id,exercise,sort,weight,reps,done FROM entries " + "WHERE session_id=? ORDER BY sort", (sid,)).fetchall() + return [dict(r) for r in rows] + finally: + if own: + con.close() + + +def fetch_sessions(user_id, activity=None, status=None, + from_d=None, to_d=None, limit=None): + sql = "SELECT * FROM sessions WHERE 1=1" + params = [] + clause, extra = _user_clause(user_id) + sql += f" AND {clause}" + params += extra + if activity: + sql += " AND activity=?" + params.append(activity) + if status: + sql += " AND status=?" + params.append(status) + if from_d: + sql += " AND date>=?" + params.append(from_d) + if to_d: + sql += " AND date<=?" + params.append(to_d) + sql += " ORDER BY date DESC, id DESC" + if limit: + sql += " LIMIT ?" + params.append(limit) + con = db() + rows = con.execute(sql, params).fetchall() + ids = [r["id"] for r in rows] + entries = {} + if ids: + ph = ",".join("?" * len(ids)) + for e in con.execute( + f"SELECT * FROM entries WHERE session_id IN ({ph}) " + "ORDER BY session_id, sort", ids).fetchall(): + entries.setdefault(e["session_id"], []).append(dict(e)) + con.close() + out = [] + for r in rows: + d = dict(r) + d["fields"] = json.loads(d.get("fields") or "{}") + d["entries"] = entries.get(d["id"], []) + out.append(d) + return out + + +def fetch_session(user_id, sid): + con = db() + clause, extra = _user_clause(user_id) + r = con.execute(f"SELECT * FROM sessions WHERE id=? AND {clause}", + [sid] + extra).fetchone() + con.close() + if not r: + return None + d = dict(r) + d["fields"] = json.loads(d.get("fields") or "{}") + d["entries"] = entries_for_session(sid) + return d + + +def create_session(user_id, data): + activity = _clean_str(data.get("activity")) + if not activity: + raise ValueError("activity erforderlich") + sdate = _clean_str(data.get("date")) or date.today().isoformat() + day = _clean_str(data.get("day")) + start = _clean_str(data.get("start_time")) or datetime.now().strftime("%H:%M") + con = db() + cur = con.execute( + "INSERT INTO sessions(activity,day,date,start_time,status,created_at,user_id) " + "VALUES(?,?,?,?,?,?,?)", + (activity, day, sdate, start, "active", + datetime.now().isoformat(timespec="seconds"), user_id)) + con.commit() + sid = cur.lastrowid + con.close() + # Optionale Vorbefuellung: letztes Workout und/oder Plan-Gewichte + settings = get_settings(user_id) + if settings.get("autoReset") or settings.get("planPrefill"): + act = find_activity_config(activity) + if act and act.get("type") == "strength": + fill_session_start(user_id, sid) + return fetch_session(user_id, sid) + + +def _reconcile_sets(con, sid, sets): + """Set-Zustand einer Uebung aktualisieren, ohne andere Uebungen anzufassen. + + Reconcile ist pro Uebung (exercise) abgegrenzt: + - Saetze werden ueber (session_id, exercise, sort) geupsert -> stabile IDs. + - Nur Saetze genau dieser Uebung, die im Payload fehlen, werden geloescht. + Eine Aenderung in Uebung A beruehrt Uebung B strukturell nie. + """ + by_ex = {} + for s in sets: + if not isinstance(s, dict): + continue + ex = _clean_str(s.get("exercise")) + if not ex: + continue + by_ex.setdefault(ex, []).append(s) + for ex, items in by_ex.items(): + for i, s in enumerate(items): + sort = _num(s.get("sort"), i) + weight = _clean_str(s.get("weight")) + reps = _clean_str(s.get("reps")) + done = 1 if s.get("done") else 0 + row = con.execute( + "SELECT id FROM entries WHERE session_id=? AND exercise=? AND sort=?", + (sid, ex, sort)).fetchone() + if row: + con.execute( + "UPDATE entries SET weight=?,reps=?,done=? WHERE id=?", + (weight, reps, done, row["id"])) + else: + con.execute( + "INSERT INTO entries(session_id,exercise,sort,weight,reps,done) " + "VALUES(?,?,?,?,?,?)", + (sid, ex, sort, weight, reps, done)) + sorts = [_num(s.get("sort"), i) for i, s in enumerate(items)] + if sorts: + ph = ",".join("?" * len(sorts)) + con.execute( + f"DELETE FROM entries WHERE session_id=? AND exercise=? " + f"AND sort NOT IN ({ph})", + [sid, ex] + sorts) + + +def update_session(user_id, sid, data): + con = db() + clause, extra = _user_clause(user_id) + row = con.execute(f"SELECT * FROM sessions WHERE id=? AND {clause}", + [sid] + extra).fetchone() + if not row: + con.close() + return None + meta = data.get("meta") or {} + if isinstance(meta, dict) and meta: + changes = [] + params = [] + for col in ("date", "day", "start_time", "end_time", "note", "status"): + if col in meta: + changes.append(f"{col}=?") + params.append(_clean_str(meta[col])) + if "fields" in meta and isinstance(meta["fields"], dict): + changes.append("fields=?") + params.append(json.dumps(meta["fields"], ensure_ascii=False)) + if changes: + params.append(sid) + con.execute(f"UPDATE sessions SET {','.join(changes)} WHERE id=?", params) + # Abschliessen ohne explizite Endzeit: Serverzeit eintragen. + if meta.get("status") == "done" and not row["end_time"]: + con.execute("UPDATE sessions SET end_time=? WHERE id=?", + (datetime.now().strftime("%H:%M"), sid)) + if "sets" in data and isinstance(data["sets"], list): + _reconcile_sets(con, sid, data["sets"]) + con.commit() + con.close() + return fetch_session(user_id, sid) + + +def delete_session(user_id, sid): + con = db() + clause, extra = _user_clause(user_id) + row = con.execute(f"SELECT id FROM sessions WHERE id=? AND {clause}", + [sid] + extra).fetchone() + if not row: + con.close() + return 0 + con.execute("DELETE FROM entries WHERE session_id=?", (sid,)) + cur = con.execute("DELETE FROM sessions WHERE id=?", (sid,)) + con.commit() + deleted = cur.rowcount + con.close() + return deleted + + +# --------------------------------------------------------------------------- +# Einstellungen +# --------------------------------------------------------------------------- + +def get_settings(user_id=OPEN_USER): + """Vollstaendige Einstellungen, typ-korrekt. + + Schichtung: Defaults < Server-Standard (user_id=0, gesetzt im + Ersteinrichtungs-Wizard) < eigene Werte des Benutzers. + """ + con = db() + out = dict(DEFAULT_SETTINGS) + + def apply(uid): + for r in con.execute("SELECT key,value FROM settings WHERE user_id=?", + (uid,)).fetchall(): + k, v = r["key"], r["value"] + if k not in out: + continue + if isinstance(out[k], bool): + out[k] = str(v).lower() in ("1", "true", "yes", "on") + else: + out[k] = str(v) + + if user_id != OPEN_USER: + apply(OPEN_USER) # Server-Standard (Sprache/Akzent aus dem Wizard) + apply(user_id) + con.close() + return out + + +def update_settings(user_id, patch): + if not isinstance(patch, dict): + raise ValueError("patch muss ein Objekt sein") + con = db() + bool_keys = ("autoReset", "showTips", "setupDone", "onboarded", "oled", "planPrefill") + for k, v in patch.items(): + if k not in DEFAULT_SETTINGS: + continue + if k in bool_keys: + v = bool(v) + else: + v = str(v) + con.execute( + "INSERT INTO settings(user_id,key,value) VALUES(?,?,?) " + "ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value", + (user_id, k, json.dumps(v) if isinstance(v, bool) else v)) + # Der erste Benutzer (Owner) setzt Sprache/Akzent auch als Server-Standard + if user_id == 1: + for k in ("language", "accent"): + if k in patch: + con.execute( + "INSERT INTO settings(user_id,key,value) VALUES(?,?,?) " + "ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value", + (OPEN_USER, k, patch[k])) + con.commit() + con.close() + return get_settings(user_id) + + +# --------------------------------------------------------------------------- +# Benutzer & Authentifizierung +# --------------------------------------------------------------------------- + +def _hash_pw(password, salt=None): + if salt is None: + salt = secrets.token_hex(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), + salt.encode("ascii"), PBKDF2_ITERATIONS) + return salt, dk.hex() + + +def _verify_pw(password, salt, expected): + _, dk = _hash_pw(password, salt) + return hmac.compare_digest(dk, expected) + + +def create_user(username, password): + username = _clean_str(username) + password = _clean_str(password) + if len(username) < 2: + raise ValueError("Benutzername zu kurz (mindestens 2 Zeichen)") + if len(password) < 4: + raise ValueError("Passwort zu kurz (mindestens 4 Zeichen)") + salt, pw_hash = _hash_pw(password) + con = db() + try: + cur = con.execute( + "INSERT INTO users(username,password_hash,salt,created_at) " + "VALUES(?,?,?,?)", + (username, pw_hash, salt, + datetime.now().isoformat(timespec="seconds"))) + uid = cur.lastrowid + except sqlite3.IntegrityError: + con.close() + raise ValueError("Benutzername ist bereits vergeben") + # Erster Benutzer uebernimmt die Daten aus dem offenen Modus: + # - Sessions wandern zum Owner (Alt-Daten gehoeren ihm), + # - Settings (Sprache/Akzent aus dem Wizard) werden KOPIERT, damit der + # Server-Standard (user_id=0) fuer weitere Benutzer erhalten bleibt. + if uid == 1: + con.execute("UPDATE sessions SET user_id=? WHERE user_id=?", + (uid, OPEN_USER)) + con.execute( + "INSERT INTO settings(user_id,key,value) " + "SELECT ?, key, value FROM settings WHERE user_id=?", + (uid, OPEN_USER)) + token = _issue_token(con, uid) + con.commit() + con.close() + return uid, username, token + + +def _issue_token(con, user_id): + token = secrets.token_hex(32) + con.execute( + "INSERT INTO tokens(token_hash,user_id,created_at) VALUES(?,?,?)", + (hashlib.sha256(token.encode("ascii")).hexdigest(), user_id, + datetime.now().isoformat(timespec="seconds"))) + return token + + +def login_user(username, password): + con = db() + row = con.execute("SELECT * FROM users WHERE username=?", + (_clean_str(username),)).fetchone() + if not row: + con.close() + return None + if not _verify_pw(password, row["salt"], row["password_hash"]): + con.close() + return None + token = _issue_token(con, row["id"]) + con.commit() + con.close() + return row["id"], row["username"], token + + +def logout_token(token): + h = hashlib.sha256(token.encode("ascii")).hexdigest() + con = db() + con.execute("DELETE FROM tokens WHERE token_hash=?", (h,)) + con.commit() + con.close() + + +def resolve_user(token): + if not token: + return None + h = hashlib.sha256(token.encode("ascii")).hexdigest() + con = db() + row = con.execute("SELECT user_id FROM tokens WHERE token_hash=?", + (h,)).fetchone() + con.close() + return row["user_id"] if row else None + + +def list_users(): + con = db() + rows = con.execute("SELECT id,username,created_at FROM users ORDER BY id" + ).fetchall() + con.close() + return [dict(r) for r in rows] + + +def delete_user(uid): + con = db() + sid_rows = con.execute("SELECT id FROM sessions WHERE user_id=?", + (uid,)).fetchall() + for r in sid_rows: + con.execute("DELETE FROM entries WHERE session_id=?", (r["id"],)) + con.execute("DELETE FROM sessions WHERE user_id=?", (uid,)) + con.execute("DELETE FROM settings WHERE user_id=?", (uid,)) + con.execute("DELETE FROM tokens WHERE user_id=?", (uid,)) + cur = con.execute("DELETE FROM users WHERE id=?", (uid,)) + con.commit() + deleted = cur.rowcount + con.close() + return deleted + + +def change_own_password(uid, current, new): + """Eigenes Passwort aendern. True bei Erfolg, False bei falschem altem.""" + new = _clean_str(new) + if len(new) < 4: + raise ValueError("Passwort zu kurz (mindestens 4 Zeichen)") + con = db() + row = con.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if not row or not _verify_pw(current, row["salt"], row["password_hash"]): + con.close() + return False + salt, pw_hash = _hash_pw(new) + con.execute("UPDATE users SET password_hash=?, salt=? WHERE id=?", + (pw_hash, salt, uid)) + con.commit() + con.close() + return True + + +def set_user_password(uid, new): + """Passwort eines Benutzers setzen/zuruecksetzen (Owner).""" + new = _clean_str(new) + if len(new) < 4: + raise ValueError("Passwort zu kurz (mindestens 4 Zeichen)") + salt, pw_hash = _hash_pw(new) + con = db() + row = con.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone() + if not row: + con.close() + return False + con.execute("UPDATE users SET password_hash=?, salt=? WHERE id=?", + (pw_hash, salt, uid)) + # Sicherheitshalber alte Tokens des Benutzers invalidieren + con.execute("DELETE FROM tokens WHERE user_id=?", (uid,)) + con.commit() + con.close() + return True + + +# --------------------------------------------------------------------------- +# Auto-Reset: letztes Workout einer Kategorie laden +# --------------------------------------------------------------------------- + +def last_workout_sets(user_id, exercise, exclude_session=None): + """Gefuellte Saetze der letzten abgeschlossenen Session einer Uebung. + + Liefert nur Saetze mit Gewicht, in Original-Reihenfolge. Die (heutige) + in-Arbeit-Session zaehlt nie - nur status='done'. + """ + con = db() + clause, extra = _user_clause(user_id, alias="s.") + row = con.execute( + f"SELECT e.session_id FROM entries e " + f"JOIN sessions s ON s.id=e.session_id " + f"WHERE e.exercise=? AND s.status='done' AND e.weight!='' " + f"AND {clause} AND s.id!=? " + f"ORDER BY s.date DESC, s.id DESC LIMIT 1", + [exercise] + extra + [exclude_session or -1]).fetchone() + if not row: + con.close() + return [] + sets = con.execute( + "SELECT weight,reps FROM entries WHERE session_id=? AND exercise=? " + "AND weight!='' ORDER BY sort", + (row["session_id"], exercise)).fetchall() + con.close() + return [{"weight": s["weight"], "reps": s["reps"]} for s in sets] + + +def _replace_sets(con, sid, ex_key, sets): + """Saetze einer Uebung komplett ersetzen (done=0).""" + con.execute("DELETE FROM entries WHERE session_id=? AND exercise=?", + (sid, ex_key)) + for i, s in enumerate(sets): + con.execute( + "INSERT INTO entries(session_id,exercise,sort,weight,reps,done) " + "VALUES(?,?,?,?,?,0)", + (sid, ex_key, i, s.get("weight", ""), s.get("reps", ""))) + + +def fill_session_start(user_id, sid): + """Frische Session beim Start vorbefuellen (pro Uebung): + + - autoReset: letztes abgeschlossenes Workout der Uebung (nur gefuellte + Saetze); hat Vorrang. + - planPrefill: Plan-Gewichte (startKg) aus der Config, Anzahl wie im Plan. + Uebungen ohne Quelle bleiben leer. + """ + con = db() + sess = con.execute("SELECT * FROM sessions WHERE id=?", (sid,)).fetchone() + if not sess: + con.close() + return + exercises = [] + act = find_activity_config(sess["activity"]) + if act and act.get("type") == "strength": + for d in act.get("cycle", {}).get("days", []): + if d.get("key") == sess["day"]: + exercises = d.get("exercises", []) + break + settings = get_settings(user_id) + auto = settings.get("autoReset") + plan = settings.get("planPrefill") + if not auto and not plan: + con.close() + return + for ex in exercises: + ex_key = ex.get("key") + if auto: + sets = last_workout_sets(user_id, ex_key, exclude_session=sid) + if sets: + _replace_sets(con, sid, ex_key, sets) + continue + if plan: + cfg = ex.get("sets") or {} + count = len(cfg.get("list", [])) if cfg.get("list") else cfg.get("count", 0) + start = ex.get("startKg") or "" + if count: + _replace_sets(con, sid, ex_key, + [{"weight": start, "reps": ""}] * count) + con.commit() + con.close() + + +def autofill_session(user_id, sid, exercises=None): + """Setze die Saetze der Session auf das letzte abgeschlossene Workout. + + exercises: Liste von Uebungs-Schluesseln. None = alle Uebungen des + Sessions-Tags (aus der Config) plus alle, die bereits Saetze haben. + Nur Uebungen mit Verlauf werden ersetzt; die Abhak-Status werden + zurueckgesetzt (frische Session). + """ + con = db() + clause, extra = _user_clause(user_id) + sess = con.execute(f"SELECT * FROM sessions WHERE id=? AND {clause}", + [sid] + extra).fetchone() + if not sess: + con.close() + return None + if exercises is None: + exercises = [] + act = find_activity_config(sess["activity"]) + if act and act.get("type") == "strength": + for d in act.get("cycle", {}).get("days", []): + if d.get("key") == sess["day"]: + exercises = [ex["key"] for ex in d.get("exercises", [])] + break + for r in con.execute( + "SELECT DISTINCT exercise FROM entries WHERE session_id=?", + (sid,)).fetchall(): + if r["exercise"] not in exercises: + exercises.append(r["exercise"]) + filled = {} + for ex in exercises: + sets = last_workout_sets(user_id, ex, exclude_session=sid) + if sets: + filled[ex] = sets + for ex, sets in filled.items(): + con.execute("DELETE FROM entries WHERE session_id=? AND exercise=?", + (sid, ex)) + for i, s in enumerate(sets): + con.execute( + "INSERT INTO entries(session_id,exercise,sort,weight,reps,done) " + "VALUES(?,?,?,?,?,0)", + (sid, ex, i, s["weight"], s["reps"])) + con.commit() + con.close() + return fetch_session(user_id, sid) + + +def reset_values(user_id, exercise, set_idx, exclude_session=None): + """Letztes abgeschlossenes Gewicht/Reps eines Sets. + + In-Arbeit-Sessions (status=active) zaehlen nie mit - nur abgeschlossene + Tage bis einschliesslich heute. + """ + today = date.today().isoformat() + con = db() + clause, extra = _user_clause(user_id, alias="s.") + row = con.execute( + f"SELECT e.weight, e.reps FROM entries e " + f"JOIN sessions s ON s.id=e.session_id " + f"WHERE e.exercise=? AND e.sort=? AND s.status='done' " + f"AND e.weight!='' AND s.date<=? AND {clause} AND s.id!=? " + f"ORDER BY s.date DESC, s.id DESC LIMIT 1", + [exercise, set_idx, today] + extra + [exclude_session or -1]).fetchone() + con.close() + if not row: + return {} + return {"weight": row["weight"], "reps": row["reps"]} + + +def stats_for_exercise(user_id, exercise): + """Bestes Gewicht je Termin + Differenz zur ersten Messung.""" + con = db() + clause, extra = _user_clause(user_id, alias="s.") + rows = con.execute( + f"SELECT s.date, e.weight, e.reps FROM entries e " + f"JOIN sessions s ON s.id=e.session_id " + f"WHERE e.exercise=? AND s.status='done' AND e.weight!='' " + f"AND {clause} " + f"ORDER BY s.date ASC, s.id ASC, e.sort ASC", + [exercise] + extra).fetchall() + con.close() + best = {} + reps = {} + for r in rows: + w = _parse_num(r["weight"]) + if w is None: + continue + d = r["date"] + if d not in best or w > best[d]: + best[d] = w + reps[d] = r["reps"] + first = None + out = [] + for d in sorted(best): + if first is None: + first = best[d] + out.append({ + "date": d, + "weight": best[d], + "reps": reps[d], + "delta": round(best[d] - first, 2), + }) + return out + + +# --------------------------------------------------------------------------- +# Workouts-API: abgeschlossene Sessions, Uebungen gruppiert +# --------------------------------------------------------------------------- + +def fetch_workouts(user_id, activity=None, from_d=None, to_d=None, limit=None): + sessions = fetch_sessions(user_id, activity=activity, status="done", + from_d=from_d, to_d=to_d, limit=limit) + out = [] + for s in sessions: + act = find_activity_config(s["activity"]) + by_ex = {} + for e in s["entries"]: + by_ex.setdefault(e["exercise"], []).append(e) + exercises = [] + for ex_key, sets in by_ex.items(): + ex_cfg = find_exercise_config(ex_key) + exercises.append({ + "exercise": ex_key, + "name": ex_cfg["name"] if ex_cfg else ex_key, + "sets": sorted( + [{"sort": x["sort"], "weight": x["weight"], + "reps": x["reps"], "done": x["done"]} for x in sets], + key=lambda x: x["sort"]), + }) + duration = None + if s["start_time"] and s["end_time"]: + try: + h1, m1 = (int(x) for x in s["start_time"].split(":")) + h2, m2 = (int(x) for x in s["end_time"].split(":")) + duration = (h2 * 60 + m2 - (h1 * 60 + m1)) % (24 * 60) + except Exception: + duration = None + out.append({ + "id": s["id"], + "activity": s["activity"], + "activity_name": act["name"] if act else s["activity"], + "day": s["day"], + "date": s["date"], + "start_time": s["start_time"], + "end_time": s["end_time"], + "duration_min": duration, + "note": s["note"], + "exercises": exercises, + "fields": s["fields"], + }) + return out + + +# --------------------------------------------------------------------------- +# Export / Import +# --------------------------------------------------------------------------- + +EXPORT_FORMAT = "trainingsplan-export-v2" + + +def export_json(user_id): + return { + "format": EXPORT_FORMAT, + "exported_at": datetime.now().isoformat(timespec="seconds"), + "config": CONFIG, + "sessions": fetch_sessions(user_id), + } + + +def export_csv(user_id): + keys = cardio_keys() + header = (["session_id", "activity", "day", "date", "start_time", + "end_time", "note", "status", "kind", "exercise", "set", + "weight", "reps", "done"] + keys) + bio = io.StringIO() + writer = csv.writer(bio) + writer.writerow(header) + for s in fetch_sessions(user_id): + base = [s["id"], s["activity"], s["day"], s["date"], s["start_time"], + s["end_time"], s["note"], s["status"]] + if s["entries"]: + for e in s["entries"]: + row = base + ["set", e["exercise"], e["sort"] + 1, + e["weight"], e["reps"], e["done"]] + row += [s["fields"].get(k, "") for k in keys] + writer.writerow(row) + else: + row = base + ["cardio", "", "", "", "", ""] + row += [s["fields"].get(k, "") for k in keys] + writer.writerow(row) + return "\ufeff" + bio.getvalue() + + +def import_json_doc(user_id, doc): + con = db() + created = updated = 0 + demo_ids = {s.get("id") for s in doc.get("sessions") or [] + if isinstance(s, dict) and isinstance(s.get("id"), int)} + next_free = None + + def _next_free(): + # Freie ID oberhalb aller vorhandenen UND aller Import-IDs, damit + # keine Kollision mit spaeteren Sessions desselben Imports entsteht. + nonlocal next_free + if next_free is None: + mdb = con.execute( + "SELECT COALESCE(MAX(id),0) AS m FROM sessions").fetchone()["m"] + mdoc = max(demo_ids) if demo_ids else 0 + next_free = max(mdb, mdoc) + 1 + return next_free + + for s in doc.get("sessions") or []: + if not isinstance(s, dict): + continue + sid = s.get("id") + meta = ( + _clean_str(s.get("activity")), + _clean_str(s.get("day")), + _clean_str(s.get("date")), + _clean_str(s.get("start_time")), + _clean_str(s.get("end_time")), + _clean_str(s.get("note")), + json.dumps(s.get("fields") or {}, ensure_ascii=False), + s.get("status") or "done", + ) + clause, extra = _user_clause(user_id) + if sid and con.execute(f"SELECT id FROM sessions WHERE id=? AND {clause}", + [sid] + extra).fetchone(): + con.execute( + "UPDATE sessions SET activity=?,day=?,date=?,start_time=?," + "end_time=?,note=?,fields=?,status=? WHERE id=?", + meta + (sid,)) + con.execute("DELETE FROM entries WHERE session_id=?", (sid,)) + updated += 1 + else: + # ID uebernehmen, wenn frei (Backup-Wiederherstellung); sonst + # freie ID oberhalb aller bekannten IDs (keine Kollision). + if sid and not con.execute("SELECT id FROM sessions WHERE id=?", + (sid,)).fetchone(): + cur = con.execute( + "INSERT INTO sessions(id,activity,day,date,start_time," + "end_time,note,fields,status,created_at,user_id) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [sid] + list(meta) + + [datetime.now().isoformat(timespec="seconds"), user_id]) + sid = cur.lastrowid + else: + nf = _next_free() + cur = con.execute( + "INSERT INTO sessions(id,activity,day,date,start_time," + "end_time,note,fields,status,created_at,user_id) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [nf] + list(meta) + + [datetime.now().isoformat(timespec="seconds"), user_id]) + sid = nf + next_free += 1 + created += 1 + for i, e in enumerate(s.get("entries") or []): + if not isinstance(e, dict): + continue + con.execute( + "INSERT INTO entries(session_id,exercise,sort,weight,reps,done) " + "VALUES(?,?,?,?,?,?)", + (sid, _clean_str(e.get("exercise")), i, + _clean_str(e.get("weight")), _clean_str(e.get("reps")), + 1 if e.get("done") else 0)) + con.commit() + con.close() + return created, updated + + +def import_csv_text(user_id, text): + text = text.lstrip("\ufeff") + reader = csv.DictReader(io.StringIO(text)) + keys = cardio_keys() + groups = {} + order = [] + for r in reader: + sid = r.get("session_id") or "" + if sid.strip().isdigit(): + key = "sid:" + sid + else: + key = "|".join([r.get("activity", ""), r.get("day", ""), + r.get("date", ""), r.get("start_time", ""), + r.get("end_time", ""), r.get("note", "")]) + if key not in groups: + groups[key] = {"rows": [], "sid": sid if sid.strip().isdigit() else None} + order.append(key) + groups[key]["rows"].append(r) + con = db() + clause, extra = _user_clause(user_id) + created = updated = 0 + demo_ids = set() + for r in reader: + sid = r.get("session_id") or "" + if sid.strip().isdigit(): + demo_ids.add(int(sid)) + next_free = None + + def _next_free(): + nonlocal next_free + if next_free is None: + mdb = con.execute( + "SELECT COALESCE(MAX(id),0) AS m FROM sessions").fetchone()["m"] + mdoc = max(demo_ids) if demo_ids else 0 + next_free = max(mdb, mdoc) + 1 + return next_free + + for key in order: + g = groups[key] + rows = g["rows"] + first = rows[0] + fields = {} + for k in keys: + v = _clean_str(first.get(k)) + if v: + fields[k] = v + meta = (_clean_str(first.get("activity")), _clean_str(first.get("day")), + _clean_str(first.get("date")), _clean_str(first.get("start_time")), + _clean_str(first.get("end_time")), _clean_str(first.get("note")), + json.dumps(fields, ensure_ascii=False), + _clean_str(first.get("status")) or "done") + sid = g["sid"] + if sid and con.execute(f"SELECT id FROM sessions WHERE id=? AND {clause}", + [sid] + extra).fetchone(): + con.execute( + "UPDATE sessions SET activity=?,day=?,date=?,start_time=?," + "end_time=?,note=?,fields=?,status=? WHERE id=?", + meta + (sid,)) + con.execute("DELETE FROM entries WHERE session_id=?", (sid,)) + updated += 1 + else: + if sid and not con.execute("SELECT id FROM sessions WHERE id=?", + (sid,)).fetchone(): + cur = con.execute( + "INSERT INTO sessions(id,activity,day,date,start_time," + "end_time,note,fields,status,created_at,user_id) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [sid] + list(meta) + + [datetime.now().isoformat(timespec="seconds"), user_id]) + sid = cur.lastrowid + else: + nf = _next_free() + cur = con.execute( + "INSERT INTO sessions(id,activity,day,date,start_time," + "end_time,note,fields,status,created_at,user_id) " + "VALUES(?,?,?,?,?,?,?,?,?,?,?)", + [nf] + list(meta) + + [datetime.now().isoformat(timespec="seconds"), user_id]) + sid = nf + next_free += 1 + created += 1 + for r in rows: + if _clean_str(r.get("kind")) != "set": + continue + set_no = _num(r.get("set"), 1) or 1 + con.execute( + "INSERT INTO entries(session_id,exercise,sort,weight,reps,done) " + "VALUES(?,?,?,?,?,?)", + (sid, _clean_str(r.get("exercise")), max(0, set_no - 1), + _clean_str(r.get("weight")), _clean_str(r.get("reps")), + 1 if _clean_str(r.get("done")) in ("1", "true", "True") else 0)) + con.commit() + con.close() + return created, updated + + +# --------------------------------------------------------------------------- +# HTTP-Handler +# --------------------------------------------------------------------------- + +class Handler(BaseHTTPRequestHandler): + server_version = "Trainingsplan/3" + + def _send(self, code, body, ctype): + b = body if isinstance(body, bytes) else body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(b))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(b) + + def _send_json(self, code, obj): + self._send(code, json.dumps(obj, ensure_ascii=False), + "application/json; charset=utf-8") + + def _read_body(self): + length = self.headers.get("Content-Length") + length = int(length) if length and length.isdigit() else 0 + if length <= 0: + return b"" + if length > MAX_BODY: + raise ValueError("Body zu gross") + return self.rfile.read(length) + + def _read_json(self): + raw = self._read_body() + try: + return json.loads(raw.decode("utf-8")) + except Exception: + return {} + + def _bearer_token(self): + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:].strip() + return None + + def _require_user(self): + """Gibt user_id zurueck oder sendet 401 und liefert None. + + Ohne registrierten Benutzer gilt der offene Modus (user_id=0). + Sobald ein Benutzer existiert, ist fuer alle Daten ein Token noetig. + """ + if user_count() == 0: + return OPEN_USER + uid = resolve_user(self._bearer_token()) + if uid is not None: + return uid + self._send(401, json.dumps({"error": "Anmeldung erforderlich"}), + "application/json; charset=utf-8") + return None + + def _send_file(self, path): + fname, ctype = STATIC_FILES[path] + fpath = os.path.join(BASE, fname) + if not os.path.exists(fpath): + self._send(404, "nicht gefunden", "text/plain; charset=utf-8") + return + with open(fpath, "rb") as f: + self._send(200, f.read(), ctype) + + def log_message(self, fmt, *args): + pass + + # -- GET -------------------------------------------------------------- + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + q = parse_qs(parsed.query) + try: + if path in STATIC_FILES: + self._send_file(path) + elif path in ("/api/meta", "/api/meta/"): + now = datetime.now() + self._send_json(200, { + "today": date.today().isoformat(), + "now": now.strftime("%H:%M"), + "auth_required": user_count() > 0, + "users": user_count(), + "language": get_settings(OPEN_USER)["language"], + }) + elif path in ("/api/me", "/api/me/"): + uid = self._require_user() + if uid is None: + return + if uid == OPEN_USER: + self._send_json(200, {"username": None, "is_owner": False}) + else: + con = db() + row = con.execute("SELECT username FROM users WHERE id=?", + (uid,)).fetchone() + con.close() + self._send_json(200, {"username": row["username"], + "is_owner": uid == 1}) + else: + uid = self._require_user() + if uid is None: + return + if path in ("/api/config", "/api/config/"): + self._send_json(200, CONFIG) + elif path in ("/api/settings", "/api/settings/"): + self._send_json(200, get_settings(uid)) + elif path in ("/api/sessions", "/api/sessions/"): + self._send_json(200, fetch_sessions( + uid, + activity=q.get("activity", [None])[0], + status=q.get("status", [None])[0], + from_d=q.get("from", [None])[0], + to_d=q.get("to", [None])[0])) + elif path in ("/api/workouts", "/api/workouts/"): + limit = _num(q.get("limit", [None])[0], None) + self._send_json(200, fetch_workouts( + uid, + activity=q.get("activity", [None])[0], + from_d=q.get("from", [None])[0], + to_d=q.get("to", [None])[0], + limit=limit)) + elif path in ("/api/reset-values", "/api/reset-values/"): + exercise = q.get("exercise", [""])[0] + set_idx = _num(q.get("set", ["0"])[0], 0) or 0 + self._send_json(200, reset_values(uid, exercise, set_idx)) + elif path in ("/api/stats", "/api/stats/"): + exercise = q.get("exercise", [""])[0] + self._send_json(200, stats_for_exercise(uid, exercise)) + elif path in ("/api/users", "/api/users/"): + self.require_owner(uid, lambda: self._send_json( + 200, list_users())) + elif path in ("/api/export", "/api/export/"): + self.do_export(uid, q) + elif path == "/favicon.ico": + self._send(204, b"", "image/x-icon") + else: + self._send(404, "nicht gefunden", "text/plain; charset=utf-8") + except Exception as exc: + print(f"Fehler in {path}: {exc}", file=sys.stderr) + self._send(500, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + + def require_owner(self, uid, then): + if uid == 1: + then() + else: + self._send(403, json.dumps({"error": "nur der erste Benutzer"}), + "application/json; charset=utf-8") + + def do_export(self, uid, q): + fmt = q.get("format", ["json"])[0] + if fmt == "csv": + body = export_csv(uid) + ctype = "text/csv; charset=utf-8" + fname = "trainingsplan.csv" + else: + body = json.dumps(export_json(uid), ensure_ascii=False, indent=1) + ctype = "application/json; charset=utf-8" + fname = "trainingsplan-backup.json" + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Disposition", + f'attachment; filename="{fname}"') + self.send_header("Content-Length", str(len(body.encode("utf-8")))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body.encode("utf-8")) + + # -- POST ------------------------------------------------------------- + + def do_POST(self): + path = urlparse(self.path).path + try: + if path in ("/api/register", "/api/register/"): + self.do_register() + elif path in ("/api/login", "/api/login/"): + self.do_login() + elif path in ("/api/logout", "/api/logout/"): + logout_token(self._bearer_token() or "") + self._send_json(200, {"ok": True}) + elif path in ("/api/password", "/api/password/"): + uid = self._require_user() + if uid is None: + return + data = self._read_json() + if change_own_password(uid, data.get("current_password", ""), + data.get("new_password", "")): + self._send_json(200, {"ok": True}) + else: + self._send(400, json.dumps( + {"error": "Aktuelles Passwort ist falsch"}), + "application/json; charset=utf-8") + else: + uid = self._require_user() + if uid is None: + return + prefix = "/api/sessions/" + if path in ("/api/sessions", "/api/sessions/"): + sess = create_session(uid, self._read_json()) + self._send_json(200, sess) + elif path.startswith(prefix) and path.endswith("/autofill"): + rid = path[len(prefix):].rsplit("/", 1)[0] + if not rid.isdigit(): + self._send(400, json.dumps({"error": "ungueltige id"}), + "application/json; charset=utf-8") + return + sess = autofill_session(uid, int(rid)) + if sess is None: + self._send(404, json.dumps( + {"error": "session nicht gefunden"}), + "application/json; charset=utf-8") + else: + self._send_json(200, sess) + elif path in ("/api/users", "/api/users/"): + self.require_owner(uid, lambda: self.do_create_user()) + elif path in ("/api/import", "/api/import/"): + self.do_import(uid) + else: + self._send(404, "nicht gefunden", "text/plain; charset=utf-8") + except ValueError as exc: + self._send(400, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + except Exception as exc: + print(f"Fehler in {path}: {exc}", file=sys.stderr) + self._send(500, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + + def do_register(self): + if user_count() > 0: + self._send(403, json.dumps( + {"error": "Registrierung geschlossen, es existiert bereits ein Benutzer"}), + "application/json; charset=utf-8") + return + data = self._read_json() + uid, username, token = create_user( + data.get("username", ""), data.get("password", "")) + self._send_json(200, {"token": token, "username": username, "id": uid}) + + def do_login(self): + data = self._read_json() + result = login_user(data.get("username", ""), data.get("password", "")) + if result is None: + self._send(401, json.dumps({"error": "Benutzername oder Passwort falsch"}), + "application/json; charset=utf-8") + return + uid, username, token = result + self._send_json(200, {"token": token, "username": username, "id": uid}) + + def do_create_user(self): + data = self._read_json() + uid, username, _ = create_user(data.get("username", ""), + data.get("password", "")) + self._send_json(200, {"id": uid, "username": username}) + + # -- PATCH / PUT ------------------------------------------------------ + + def do_PATCH(self): + path = urlparse(self.path).path + try: + uid = self._require_user() + if uid is None: + return + prefix = "/api/sessions/" + if path.startswith(prefix): + rid = path[len(prefix):].split("/")[0] + if not rid.isdigit(): + self._send(400, json.dumps({"error": "ungueltige id"}), + "application/json; charset=utf-8") + return + sess = update_session(uid, int(rid), self._read_json()) + if sess is None: + self._send(404, json.dumps( + {"error": "session nicht gefunden"}), + "application/json; charset=utf-8") + else: + self._send_json(200, sess) + else: + self._send(404, "nicht gefunden", "text/plain; charset=utf-8") + except Exception as exc: + print(f"Fehler in {path}: {exc}", file=sys.stderr) + self._send(500, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + + def do_PUT(self): + path = urlparse(self.path).path + try: + if path in ("/api/settings", "/api/settings/"): + uid = self._require_user() + if uid is None: + return + self._send_json(200, update_settings(uid, self._read_json())) + elif path.startswith("/api/users/") and path.endswith("/password"): + uid = self._require_user() + if uid is None: + return + if uid != 1: + self._send(403, json.dumps( + {"error": "nur der erste Benutzer"}), + "application/json; charset=utf-8") + return + rid = path[len("/api/users/"):].rsplit("/", 1)[0] + if not rid.isdigit(): + self._send(400, json.dumps({"error": "ungueltige id"}), + "application/json; charset=utf-8") + return + if set_user_password(int(rid), + self._read_json().get("password", "")): + self._send_json(200, {"ok": True}) + else: + self._send(404, json.dumps({"error": "benutzer nicht gefunden"}), + "application/json; charset=utf-8") + else: + self._send(404, "nicht gefunden", "text/plain; charset=utf-8") + except ValueError as exc: + self._send(400, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + except Exception as exc: + print(f"Fehler in {path}: {exc}", file=sys.stderr) + self._send(500, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + + # -- DELETE ------------------------------------------------------------ + + def do_DELETE(self): + path = urlparse(self.path).path + try: + uid = self._require_user() + if uid is None: + return + prefix = "/api/sessions/" + if path.startswith(prefix): + rid = path[len(prefix):].split("/")[0] + if not rid.isdigit(): + self._send(400, json.dumps({"error": "ungueltige id"}), + "application/json; charset=utf-8") + return + deleted = delete_session(uid, int(rid)) + self._send_json(200, {"ok": True, "deleted": deleted}) + elif path.startswith("/api/users/"): + if uid != 1: + self._send(403, json.dumps( + {"error": "nur der erste Benutzer"}), + "application/json; charset=utf-8") + return + rid = path[len("/api/users/"):].split("/")[0] + if not rid.isdigit() or int(rid) == uid: + self._send(400, json.dumps( + {"error": "ungueltige id oder eigener Account"}), + "application/json; charset=utf-8") + return + deleted = delete_user(int(rid)) + self._send_json(200, {"ok": True, "deleted": deleted}) + else: + self._send(404, "nicht gefunden", "text/plain; charset=utf-8") + except Exception as exc: + print(f"Fehler in {path}: {exc}", file=sys.stderr) + self._send(500, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + + def do_import(self, uid): + data = self._read_json() + fmt = data.get("format", "") + raw = data.get("data", "") + try: + if fmt == EXPORT_FORMAT: + doc = json.loads(raw) + created, updated = import_json_doc(uid, doc) + elif fmt == "csv": + created, updated = import_csv_text(uid, raw) + else: + self._send(400, json.dumps( + {"error": f"unbekanntes format: {fmt}"}), + "application/json; charset=utf-8") + return + self._send_json(200, {"ok": True, "created": created, + "updated": updated}) + except Exception as exc: + self._send(400, json.dumps({"error": str(exc)}), + "application/json; charset=utf-8") + + +def main(): + port = int(os.environ.get("PORT", "8080")) + if len(sys.argv) > 1 and sys.argv[1].isdigit(): + port = int(sys.argv[1]) + load_config() + init_db() + print(f"Trainingsplan auf Port {port}, DB: {DB}") + ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1e8f0ae --- /dev/null +++ b/styles.css @@ -0,0 +1,1132 @@ +/* =========================================================================== + Trainingsplan - Styles + Richtung: hart, dunkel, flach. 0px Radius, 2px-Borders, ein Akzent. + Themen (dark/light/oled) und Akzentfarbe kommen als data-Attribute auf + und werden aus den Einstellungen gesetzt. + =========================================================================== */ + +:root { + --bg: #0e0e0e; + --bg-panel: #161616; + --bg-input: #0a0a0a; + --bg-hover: #202020; + --bg-subtle: #1d1d1d; + --border: #333; + --border-hover: #4d4d4d; + --text: #e8e8e8; + --muted: #8f8f8f; + --accent: #ffffff; + --accent-hover: #f2f2f2; + --accent-active: #e6e6e6; + --accent-ink: #141414; + --danger: #c0392b; + --ok: #6fae5c; + --font-ui: system-ui, -apple-system, "Segoe UI", sans-serif; + --font-data: ui-monospace, "SF Mono", "DejaVu Sans Mono", "Cascadia Mono", monospace; +} + +html[data-theme="light"] { + --bg: #f2f2f0; + --bg-panel: #ffffff; + --bg-input: #ffffff; + --bg-hover: #e7e7e4; + --bg-subtle: #e4e4e0; + --border: #c6c6c2; + --border-hover: #9a9a96; + --text: #1c1c1c; + --muted: #6b6b68; + --danger: #b03a2e; + --ok: #3f8f4a; +} + +html[data-theme="oled"] { + --bg: #000000; + --bg-panel: #050505; + --bg-input: #000000; + --bg-hover: #0e0e0e; + --bg-subtle: #0a0a0a; + --border: #232323; + --border-hover: #3a3a3a; +} + +html[data-accent="amber"] { --accent: #e0a33c; --accent-hover: #e9b257; --accent-active: #cf9130; --accent-ink: #141414; } +html[data-accent="orange"] { --accent: #e4582e; --accent-hover: #ed6a36; --accent-active: #ca4a22; --accent-ink: #141414; } +html[data-accent="red"] { --accent: #d64541; --accent-hover: #e1514c; --accent-active: #be3a37; --accent-ink: #141414; } +html[data-accent="green"] { --accent: #3fae5a; --accent-hover: #4ebc68; --accent-active: #36984b; --accent-ink: #141414; } +html[data-accent="teal"] { --accent: #2aa198; --accent-hover: #39afa5; --accent-active: #258d84; --accent-ink: #141414; } +html[data-accent="cyan"] { --accent: #25b8cc; --accent-hover: #39c5d8; --accent-active: #219fb2; --accent-ink: #141414; } +html[data-accent="blue"] { --accent: #4a86c8; --accent-hover: #5a96d6; --accent-active: #4073b0; --accent-ink: #141414; } +html[data-accent="violet"] { --accent: #9a6fd0; --accent-hover: #ab80e0; --accent-active: #875db7; --accent-ink: #141414; } +html[data-accent="pink"] { --accent: #e0567f; --accent-hover: #eb6890; --accent-active: #c74b6e; --accent-ink: #141414; } +html[data-accent="white"] { --accent: #ffffff; --accent-hover: #f2f2f2; --accent-active: #e6e6e6; --accent-ink: #141414; } + +html[data-theme="dark"], html[data-theme="oled"] { color-scheme: dark; } +html[data-theme="light"] { color-scheme: light; } + +* { box-sizing: border-box; } +*::before, *::after { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 15px; + line-height: 1.45; +} + +::selection { background: var(--accent); color: var(--accent-ink); } +::placeholder { color: var(--muted); } + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +@media (prefers-reduced-motion: reduce) { + * { transition: none; } +} + +/* =========================================================================== + Grundgeruest: Topbar + Tabs + =========================================================================== */ + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 12px 20px; + border-bottom: 2px solid var(--border); + background: var(--bg-panel); +} + +.brand { + display: flex; + flex-direction: column; + line-height: 1.1; +} + +.brand-name { + font-weight: 700; + letter-spacing: 0.02em; + font-size: 17px; +} + +.brand-date { + color: var(--muted); + font-size: 12px; +} + +.user-chip { + color: var(--muted); + font-size: 13px; + font-family: var(--font-data); + padding: 4px 10px; + border: 2px solid var(--border); +} + +.tabs { + display: flex; + gap: 0; + margin-left: auto; + border: 2px solid var(--border); +} + +.tab { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-weight: 600; + padding: 7px 18px; + cursor: pointer; +} + +.tab:hover { color: var(--text); background: var(--bg-hover); } +.tab:active { background: var(--bg-hover); } + +.tab.is-active { + background: var(--accent); + color: var(--accent-ink); + font-weight: 700; +} + +.tab.is-active:hover { background: var(--accent-hover); } + +/* =========================================================================== + Views + =========================================================================== */ + +main { + max-width: 980px; + margin: 0 auto; + padding: 24px 20px 64px; +} + +.view.is-active { display: block; } +.view:not(.is-active) { display: none; } + +h1, h2, h3 { + font-family: var(--font-ui); + margin: 0; + line-height: 1.2; +} + +h1 { font-size: 22px; font-weight: 800; } +h2 { font-size: 18px; font-weight: 700; } +h3 { font-size: 15px; font-weight: 700; } + +.muted { color: var(--muted); font-size: 13px; } + +/* =========================================================================== + Buttons + =========================================================================== */ + +.btn { + appearance: none; + border: 2px solid var(--border); + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + padding: 7px 14px; + cursor: pointer; +} + +.btn:hover { border-color: var(--border-hover); background: var(--bg-hover); } +.btn:active { background: var(--bg-hover); } + +.btn-primary { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 700; +} + +.btn-primary:hover { border-color: var(--accent-hover); background: var(--accent-hover); } +.btn-primary:active { border-color: var(--accent-active); background: var(--accent-active); } + +.btn-danger-ghost { + border-color: var(--danger); + color: var(--danger); + font-weight: 600; +} + +.btn-danger-ghost:hover { background: var(--bg-hover); } +.btn-danger-ghost.is-armed { background: var(--danger); color: #fff; } + +.btn-small { padding: 4px 10px; font-size: 13px; } +.btn-block { width: 100%; } + +.icon-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 2px solid var(--border); + background: transparent; + color: var(--muted); + font: inherit; + font-size: 15px; + line-height: 1; + cursor: pointer; +} + +.icon-btn:hover { border-color: var(--border-hover); color: var(--text); background: var(--bg-hover); } +.icon-btn:active { background: var(--bg-hover); } + +/* =========================================================================== + Heute - Aktivitaets-Karten + =========================================================================== */ + +.act-card { + border: 2px solid var(--border); + background: var(--bg-panel); + margin-bottom: 20px; +} + +.act-title { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 2px solid var(--border); +} + +.act-title h2 { font-size: 17px; } + +.act-title .act-kind { + color: var(--muted); + font-size: 12px; +} + +.start-box { padding: 16px; } + +.picker-label { + display: block; + color: var(--muted); + font-size: 13px; + margin: 0 0 10px; +} + +.day-picker { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } + +.day-btn { + appearance: none; + border: 2px solid var(--border); + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + padding: 8px 20px; + cursor: pointer; +} + +.day-btn:hover { border-color: var(--border-hover); background: var(--bg-hover); } + +.day-btn.is-active { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 700; +} + +.day-btn.is-active:hover { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +/* Session-Kopf (laufende Session) */ + +.session-head { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + padding: 10px 16px; + border-bottom: 2px solid var(--border); +} + +.session-day { + font-weight: 700; +} + +.session-when { color: var(--muted); font-size: 13px; } + +.save-ind { + font-size: 12px; + color: var(--muted); +} + +.save-ind.is-saving { color: var(--accent); } +.save-ind.is-error { color: var(--danger); } +.save-ind.is-saved { color: var(--ok); } + +.note-field { + display: block; + width: 100%; +} + +.note-input { + width: 100%; + border: 2px solid var(--border); + background: var(--bg-input); + color: var(--text); + font: inherit; + padding: 7px 10px; +} + +.note-input:focus { border-color: var(--accent); outline: 0; } + +/* Uebung aus anderen Tagen hinzufuegen */ + +.ex-picker { + border: 2px solid var(--border); + background: var(--bg-input); + color: var(--text); + font: inherit; + padding: 6px 8px; + max-width: 280px; +} + +.ex-picker:focus { border-color: var(--accent); outline: 0; } + +/* =========================================================================== + Uebungs-Block + Saetze + =========================================================================== */ + +.ex-block { + border: 2px solid var(--border); + margin: 0 0 12px; + cursor: grab; +} + +.ex-block.drag-over { border-color: var(--accent); } + +.ex-head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 8px 12px; + border-bottom: 2px solid var(--border); +} + +.ex-name { font-weight: 700; } + +.ex-hint { + color: var(--muted); + font-size: 12px; +} + +.no-tips .ex-hint { display: none; } + +.ex-actions { margin-left: auto; display: flex; gap: 6px; } + +.set-rows { padding: 0; } + +.set-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 8px 12px; +} + +.set-row.is-done { background: var(--bg-subtle); } + +/* Set-Abhaken: ganzer Button klickbar, mit Ankreuz-Box */ + +.set-check { + appearance: none; + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 0 12px; + border: 2px solid var(--border); + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + font-size: 13px; + cursor: pointer; +} + +.set-check:hover { border-color: var(--border-hover); background: var(--bg-hover); } + +.set-check-num { + color: var(--text); + font-family: var(--font-data); +} + +.set-check-box { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: 2px solid currentColor; + font-size: 11px; + line-height: 1; +} + +.set-check.is-done { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); +} + +.set-check.is-done:hover { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.set-check.is-done .set-check-num { color: inherit; } + +/* Eingabebox: Zahl links, Einheit als festes Suffix rechts in derselben Box */ + +.field-box { + display: inline-flex; + align-items: stretch; + border: 2px solid var(--border); + background: var(--bg-input); + cursor: text; +} + +.field-box:focus-within { border-color: var(--accent); } +.field-box:hover { border-color: var(--border-hover); } + +.field-input { + width: 74px; + padding: 6px 4px 6px 8px; + border: 0; + background: transparent; + color: var(--text); + font-family: var(--font-data); + font-size: 15px; + font-variant-numeric: tabular-nums; + outline: 0; +} + +.field-input::placeholder { color: var(--muted); } + +.field-unit { + display: inline-flex; + align-items: center; + padding: 0 8px; + border-left: 1px solid var(--border); + color: var(--muted); + font-family: var(--font-data); + font-size: 12px; + white-space: nowrap; +} + +/* Cardio-Felder */ + +.cardio-fields { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; +} + +.cardio-fields .field-input { width: 90px; } + +/* =========================================================================== + Hinweise / Leer-Zustaende + =========================================================================== */ + +.notice { + border: 2px solid var(--border); + background: var(--bg-panel); + color: var(--text); + padding: 10px 14px; + margin: 10px 0; + font-size: 14px; +} + +.notice .btn { margin-left: 10px; } + +.empty-box { + border: 2px dashed var(--border); + padding: 24px; + color: var(--muted); + text-align: center; +} + +/* =========================================================================== + Verlauf + =========================================================================== */ + +.hist-group { margin-bottom: 18px; } + +.hist-date { + font-size: 15px; + font-weight: 700; + padding: 4px 0 6px; + color: var(--text); + border-bottom: 2px solid var(--border); +} + +.hist-card { + border: 2px solid var(--border); + background: var(--bg-panel); + margin: 0 0 10px; +} + +.hist-head { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 8px 12px; + border-bottom: 2px solid var(--border); +} + +.hist-when { + font-family: var(--font-data); + font-size: 13px; + color: var(--text); +} + +.hist-activity { color: var(--muted); font-size: 13px; } + +.hist-head .actions { margin-left: auto; display: flex; gap: 6px; } + +.hist-note { color: var(--muted); font-size: 13px; padding: 4px 12px; } + +.hist-entries { list-style: none; margin: 0; padding: 6px 12px; } + +.hist-entry { + padding: 2px 0; + font-size: 14px; +} + +.hist-entry .ex-name { font-weight: 600; } + +.hist-sets { color: var(--muted); } + +.hist-sets .set-done-box { + display: inline-block; + width: 12px; + margin-right: 3px; + text-align: center; + color: var(--accent); + font-weight: 700; +} + +.hist-sets .set-undone-box { + display: inline-block; + width: 12px; + margin-right: 3px; + text-align: center; +} + +/* Wochen-Uebersicht im Verlauf */ + +.week-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; + margin: 12px 0; +} + +.week-day { + border: 2px solid var(--border); + background: var(--bg-panel); + padding: 8px; + min-width: 0; +} + +.week-day-head { + font-size: 12px; + font-weight: 700; + color: var(--text); + padding: 2px 0 6px; + border-bottom: 2px solid var(--border); +} + +.week-day-head.is-today { color: var(--accent); } + +.week-session { + padding: 3px 0; + font-size: 12px; + line-height: 1.3; +} + +.week-session-title { color: var(--text); font-weight: 600; } + +/* Inline-Editor im Verlauf */ + +.hist-edit { + border: 2px solid var(--border); + background: var(--bg-panel); + padding: 12px; + margin: 0 0 10px; +} + +.hist-edit-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 8px; + margin: 0 0 10px; +} + +.hist-edit-grid label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} + +.hist-edit-grid input, +.hist-edit-grid select { + border: 2px solid var(--border); + background: var(--bg-input); + color: var(--text); + font: inherit; + padding: 6px 8px; +} + +.hist-edit-grid input:focus { border-color: var(--accent); outline: 0; } + +.hist-edit .edit-rows { margin: 0 0 10px; } + +.edit-ex { + border: 2px solid var(--border); + padding: 6px 10px; + margin: 0 0 8px; +} + +.edit-ex .ex-name { font-weight: 600; font-size: 13px; } + +.edit-ex .edit-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 4px 0; +} + +.edit-done { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.edit-actions { display: flex; gap: 8px; } + +/* =========================================================================== + Statistik + =========================================================================== */ + +.stats-pick { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 16px; +} + +.stats-pick .field-label { + font-size: 12px; + color: var(--muted); +} + +.stats-select { + border: 2px solid var(--border); + background: var(--bg-input); + color: var(--text); + font: inherit; + padding: 7px 10px; + max-width: 100%; +} + +.stats-table { + width: 100%; + border-collapse: collapse; +} + +.stats-table th, +.stats-table td { + border: 2px solid var(--border); + padding: 8px 12px; + text-align: left; + font-size: 14px; +} + +.stats-table th { background: var(--bg-subtle); font-weight: 700; } + +.stats-table td.num, +.stats-table th.num { font-family: var(--font-data); text-align: right; } + +.stats-bar-cell { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; +} + +.stats-bar { + display: inline-block; + height: 10px; + background: var(--accent); +} + +.delta-pos { color: var(--ok); font-family: var(--font-data); } +.delta-neg { color: var(--danger); font-family: var(--font-data); } +.delta-zero { color: var(--muted); font-family: var(--font-data); } + +/* =========================================================================== + Daten / Einstellungen + =========================================================================== */ + +.data-section { border: 2px solid var(--border); margin: 0 0 16px; } + +.data-section h3 { + padding: 8px 12px; + border-bottom: 2px solid var(--border); + font-size: 15px; +} + +.data-section .data-body { padding: 12px; } + +.data-section p { color: var(--muted); font-size: 13px; margin: 0 0 10px; } + +.data-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + +.file-input { + max-width: 240px; + color: var(--muted); + font-size: 13px; +} + +.import-result { + margin: 8px 0 0; + font-size: 13px; + color: var(--ok); +} + +.import-result.is-error { color: var(--danger); } + +/* Einstellungen */ + +.toggle-row { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 10px 0; + cursor: pointer; + border-bottom: 2px solid var(--border); +} + +.toggle-row:last-child { border-bottom: 0; } + +.toggle-input { + width: 20px; + height: 20px; + margin: 2px 0 0; + accent-color: var(--accent); + flex-shrink: 0; +} + +.toggle-text { display: flex; flex-direction: column; gap: 2px; } +.toggle-text strong { font-weight: 600; } +.toggle-text .muted { font-size: 12px; } + +.user-list { + list-style: none; + margin: 0 0 10px; + padding: 0; +} + +.user-row { + display: flex; + gap: 10px; + align-items: center; + padding: 6px 10px; + border: 2px solid var(--border); + margin: 0 0 6px; +} + +.user-row .muted { + margin-left: auto; + color: var(--muted); + font-size: 12px; +} + +.user-row .pw-reset { + display: flex; + gap: 8px; + align-items: center; + margin: 0 0 0 auto; +} + +.accent-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 6px; +} + +.accent-swatch { + width: 30px; + height: 30px; + border: 2px solid var(--border); + padding: 0; + cursor: pointer; +} + +.accent-swatch:hover { border-color: var(--border-hover); } + +.accent-swatch.is-active { + border-color: var(--text); + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* =========================================================================== + Anmeldung (Auth-Gate) + =========================================================================== */ + +body.no-auth .topbar, +body.no-auth main { display: none; } + +body.boot .topbar, +body.boot main, +body.boot .auth-gate, +body.boot #setup-gate { visibility: hidden; } + +.auth-gate { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); +} + +.auth-gate[hidden] { display: none; } + +.auth-box { + border: 2px solid var(--border); + background: var(--bg-panel); + max-width: 460px; + padding: 36px; + text-align: left; +} + +.auth-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 24px; +} + +.auth-title { + font-size: 20px; + font-weight: 800; + margin: 0; +} + +.auth-sub { + color: var(--muted); + font-size: 13px; + margin: 0 0 24px; +} + +.auth-form { margin: 0 0 14px; } + +.auth-field { + display: flex; + flex-direction: column; + gap: 5px; + margin: 0 0 16px; + font-size: 12px; + color: var(--muted); +} + +.auth-field input { + border: 2px solid var(--border); + background: var(--bg-input); + color: var(--text); + font: inherit; + padding: 7px 10px; +} + +.auth-field input:focus { border-color: var(--accent); outline: 0; } + +.auth-box .picker-label { margin: 0 0 12px; } + +.auth-box .day-picker { margin-bottom: 16px; } + +.auth-form { margin: 0 0 18px; } + +.auth-register { + border: 2px solid var(--border); + background: var(--bg-subtle); + padding: 8px 12px; + color: var(--text); + font-size: 13px; + margin: 0 0 10px; +} + +.auth-error { + border: 2px solid var(--danger); + background: var(--bg-subtle); + color: var(--danger); + padding: 8px 12px; + font-size: 13px; + margin: 0 0 10px; +} + +.auth-error[hidden] { display: none; } + +.wizard-title { + font-size: 16px; + font-weight: 700; + margin: 24px 0 10px; +} + +.user-row.muted-only { + color: var(--muted); + font-size: 13px; + justify-content: center; +} + +/* Ersteinrichtung: zwei gleichwertige Optionen */ + +.setup-option { + display: block; + width: 100%; + text-align: left; + border: 2px solid var(--border); + background: var(--bg-panel); + padding: 18px 20px; + font: inherit; + cursor: pointer; + margin: 0 0 10px; +} + +.setup-option:hover { border-color: var(--border-hover); background: var(--bg-hover); } + +.setup-option.is-active { + border-color: var(--accent); + background: var(--bg-subtle); +} + +.setup-option.is-active strong { color: var(--text); } +.setup-option.is-active .setup-option-desc { color: var(--text); } + +.setup-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin: 0 0 22px; +} + +.setup-options .setup-option { height: 100%; } + +.wizard-nav { + display: flex; + gap: 12px; + justify-content: space-between; + margin-top: 20px; +} + +.wizard-nav-right { + display: flex; + gap: 10px; +} + +.wizard-progress { + display: flex; + gap: 8px; +} + +.wp-step { + width: 36px; + height: 10px; + border: 2px solid var(--border); + background: transparent; +} + +.wp-step.is-done { + border-color: var(--muted); + background: var(--muted); +} + +.wp-step.is-current { + border-color: var(--accent); + background: var(--accent); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.settings-select-row { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 0 10px; + font-size: 12px; + color: var(--muted); +} + +.setup-option strong { + display: block; + font-weight: 700; + font-size: 15px; +} + +.setup-option .setup-option-desc { + display: block; + color: var(--muted); + font-size: 13px; + line-height: 1.5; + margin-top: 4px; +} + +/* =========================================================================== + Overlay (Uebungs-Erklaerung) + =========================================================================== */ + +.overlay-root { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; +} + +.overlay-root[hidden] { display: none; } + +.overlay-box { + border: 2px solid var(--border); + background: var(--bg-panel); + max-width: 560px; + max-height: 80%; + overflow: auto; + padding: 20px; +} + +.overlay-box h3 { + font-size: 18px; + font-weight: 700; + margin: 0 0 12px; +} + +.overlay-info { + margin: 0 0 16px; + font-size: 14px; + line-height: 1.6; + white-space: pre-line; +} + +.tag-chip { + display: inline-block; + border: 2px solid var(--border); + padding: 2px 10px; + color: var(--muted); + font-size: 12px; + margin: 0 4px 12px 0; +} + +.overlay-box .btn { margin-top: 14px; } + +/* =========================================================================== + Toast + =========================================================================== */ + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 200; + border: 2px solid var(--border); + background: var(--bg-panel); + color: var(--text); + padding: 10px 18px; + font-size: 14px; +} + +.toast[hidden] { display: none; } +.toast.is-error { border-color: var(--danger); color: var(--danger); } +.toast.is-ok { border-color: var(--ok); color: var(--ok); } +.toast.is-accent { border-color: var(--accent); color: var(--accent); } \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3230531 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""API-Test fuer den Trainingsplan-Server (End-to-End, offener Modus). + +Lauf: python3 tests/test_api.py +Voraussetzung: Server-Code liegt eine Ebene ueber tests/. +""" +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +import urllib.error +import urllib.parse + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATE = "/tmp/tp_state" +PORT = 8211 +BASE_URL = f"http://127.0.0.1:{PORT}" + +PASS = 0 +FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f"FAIL {name} {detail}") + + +def req(method, path, body=None, token=None): + url = BASE_URL + path + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = "Bearer " + token + r = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=5) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode("utf-8")) + except Exception: + return e.code, {} + + +def raw(method, path): + r = urllib.request.Request(BASE_URL + path, method=method) + with urllib.request.urlopen(r, timeout=5) as resp: + return resp.status, resp.read().decode("utf-8") + + +def main(): + if os.path.exists(STATE): + shutil.rmtree(STATE) + os.makedirs(STATE, exist_ok=True) + env = dict(os.environ, TRAINING_STATE_DIR=STATE, PORT=str(PORT)) + proc = subprocess.Popen( + [sys.executable, os.path.join(BASE, "server.py")], + env=env, cwd=BASE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(40): + time.sleep(0.25) + try: + urllib.request.urlopen(BASE_URL + "/api/meta", timeout=1) + break + except Exception: + pass + else: + print("Server nicht erreichbar") + sys.exit(1) + + st, meta = req("GET", "/api/meta") + check("meta", st == 200 and "today" in meta and "now" in meta, meta) + + st, cfg = req("GET", "/api/config") + acts = cfg["activities"] + check("config hat 3 Aktivitaeten", len(acts) == 3, + [a["id"] for a in acts]) + gym = next(a for a in acts if a["id"] == "gym") + check("gym normalisiert (Push/Pull/Legs)", + len(gym["cycle"]["days"]) == 3, + [d["key"] for d in gym["cycle"]["days"]]) + push = gym["cycle"]["days"][0] + ex = push["exercises"][0] + check("Uebung hat key + sets", ex["key"] == "Push/Bankdrücken KH (je Hand)" + and ex["sets"]["count"] == 4, ex) + run = next(a for a in acts if a["id"] == "run") + check("run fieldTemplates", len(run["fieldTemplates"]) == 5, + run["fieldTemplates"]) + + st, static = raw("GET", "/") + check("index.html", st == 200 and " 0, stats) + check("Statistik: bestes Gewicht je Termin", + s0["weight"] == 22 and s1["weight"] == 20, stats) + + st, exp = req("GET", "/api/export?format=json") + check("Export JSON Format", st == 200 + and exp["format"] == "trainingsplan-export-v2" + and len(exp["sessions"]) == 4, exp.get("format")) + st, body = raw("GET", "/api/export?format=csv") + check("Export CSV Header", st == 200 and "session_id" in body + and "distanceKm" in body, body[:120]) + + st, imp = req("POST", "/api/import", + {"format": "trainingsplan-export-v2", + "data": json.dumps(exp)}) + check("Import JSON (Merge) ok", st == 200 and imp["created"] == 0 + and imp["updated"] == 4, imp) + st, csvimp = req("POST", "/api/import", + {"format": "csv", "data": body}) + check("Import CSV ok", st == 200 and csvimp["updated"] == 4, csvimp) + + st, r = req("DELETE", f"/api/sessions/{cid}") + check("Session loeschen", st == 200 and r["deleted"] == 1, r) + st, r = req("DELETE", f"/api/sessions/{sid2}") + check("Session loeschen (sid2)", r.get("deleted") == 1, r) + + st, r = req("POST", "/api/sessions", {}) + check("POST ohne activity -> 400", st == 400, (st, r)) + st, r = req("PATCH", "/api/sessions/99999", {"meta": {"note": "x"}}) + check("PATCH unbekannte Session -> 404", st == 404, (st, r)) + st, r = req("GET", "/api/nonexistent") + check("unbekannter Pfad -> 404", st == 404) + + finally: + proc.terminate() + proc.wait(timeout=5) + + print(f"\n{PASS} ok, {FAIL} fail") + sys.exit(1 if FAIL else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py new file mode 100644 index 0000000..d419602 --- /dev/null +++ b/tests/test_api_settings.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""API-Test fuer Einstellungen + Auto-Reset (offener Modus). + +Lauf: python3 tests/test_api_settings.py +""" +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +import urllib.error + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATE = "/tmp/tp_state2" +PORT = 8255 +BASE_URL = f"http://127.0.0.1:{PORT}" + +PASS = 0 +FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f"FAIL {name} {detail}") + + +def req(method, path, body=None): + url = BASE_URL + path + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + r = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=5) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode("utf-8")) + except Exception: + return e.code, {} + + +def main(): + if os.path.exists(STATE): + shutil.rmtree(STATE) + os.makedirs(STATE, exist_ok=True) + env = dict(os.environ, TRAINING_STATE_DIR=STATE, PORT=str(PORT)) + proc = subprocess.Popen( + [sys.executable, os.path.join(BASE, "server.py")], + env=env, cwd=BASE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(40): + time.sleep(0.25) + try: + urllib.request.urlopen(BASE_URL + "/api/meta", timeout=1) + break + except Exception: + pass + else: + print("Server nicht erreichbar") + sys.exit(1) + + st, s = req("GET", "/api/settings") + check("Settings Defaults", st == 200 + and s["theme"] == "dark" and s["accent"] == "white" + and s["autoReset"] is False and s["showTips"] is True, s) + + st, s = req("PUT", "/api/settings", + {"theme": "light", "accent": "green", + "autoReset": True, "showTips": False}) + check("Settings speichern", st == 200 and s["theme"] == "light" + and s["accent"] == "green" and s["autoReset"] is True + and s["showTips"] is False, s) + st, s = req("GET", "/api/settings") + check("Settings persistiert", s["theme"] == "light" + and s["autoReset"] is True and s["showTips"] is False, s) + + st, s = req("PUT", "/api/settings", {"autoReset": False}) + check("Teil-Update", s["autoReset"] is False + and s["theme"] == "light", s) + req("PUT", "/api/settings", {"autoReset": True, "showTips": True}) + + ex = "Push/Bankdrücken KH (je Hand)" + st, done = req("POST", "/api/sessions", + {"activity": "gym", "day": "Push"}) + did = done["id"] + sets = [] + for i, w in enumerate(["18", "18,5", "20", ""]): + sets.append({"exercise": ex, "sort": i, "weight": w, + "reps": "10", "done": 1 if i < 3 else 0}) + req("PATCH", f"/api/sessions/{did}", {"sets": sets}) + req("PATCH", f"/api/sessions/{did}", {"meta": {"status": "done"}}) + + req("PUT", "/api/settings", {"autoReset": False}) + st, s1 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"}) + check("AutoReset OFF: keine vorbefuellten Sets", + len(s1["entries"]) == 0, s1["entries"]) + req("DELETE", f"/api/sessions/{s1['id']}") + + req("PUT", "/api/settings", {"autoReset": True}) + st, s2 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"}) + entries = s2["entries"] + check("AutoReset ON: nur 3 gefuellte Sets geladen", + len(entries) == 3, entries) + check("AutoReset ON: Gewichte uebernommen", + [e["weight"] for e in entries] == ["18", "18,5", "20"], + [e["weight"] for e in entries]) + check("AutoReset ON: Abhak-Status zurueckgesetzt", + all(e["done"] == 0 for e in entries), entries) + check("AutoReset ON: Reps uebernommen", + all(e["reps"] == "10" for e in entries), entries) + + new_sets = [{"exercise": ex, "sort": 0, "weight": "999", "reps": "1", + "done": 0}] + req("PATCH", f"/api/sessions/{s2['id']}", {"sets": new_sets}) + st, filled = req("POST", f"/api/sessions/{s2['id']}/autofill") + check("Autofill: Saetze ersetzt", st == 200 + and len(filled["entries"]) == 3 + and filled["entries"][0]["weight"] == "18", filled["entries"]) + + exB = "Push/Schrägbank KH (je Hand)" + req("PATCH", f"/api/sessions/{s2['id']}", + {"sets": [{"exercise": exB, "sort": 0, "weight": "14", "reps": "12", + "done": 0}]}) + st, filled = req("POST", f"/api/sessions/{s2['id']}/autofill") + b = [e for e in filled["entries"] if e["exercise"] == exB] + check("Autofill: Uebung ohne Verlauf unberuehrt", + b and b[0]["weight"] == "14", filled["entries"]) + + req("PUT", "/api/settings", + {"autoReset": "ja", "showTips": 1, "bogus": 42, "theme": "oled"}) + st, s = req("GET", "/api/settings") + check("Typen + unbekannte Keys", s["autoReset"] is True + and s["showTips"] is True and "bogus" not in s + and s["theme"] == "oled", s) + + # --- Plan-Gewichte vorbefuellen ------------------------------------ + req("PUT", "/api/settings", {"autoReset": False, "planPrefill": True}) + st, s3 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"}) + bd = [e for e in s3["entries"] if e["exercise"] == "Push/Bankdrücken KH (je Hand)"] + check("PlanPrefill: alle Plan-Saetze mit startKg", + len(bd) == 4 and all(e["weight"] == "18" for e in bd), bd) + check("PlanPrefill: alle Uebungen des Tags befuellt", + len({e["exercise"] for e in s3["entries"]}) == 8, + len(s3["entries"])) + check("PlanPrefill: Reps leer, done=0", + all(e["reps"] == "" and e["done"] == 0 for e in s3["entries"]), + s3["entries"]) + req("DELETE", f"/api/sessions/{s3['id']}") + # Auto-Reset hat Vorrang vor Plan-Gewichten (je Uebung) + req("PUT", "/api/settings", {"autoReset": True, "planPrefill": True}) + st, s4 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"}) + bd4 = [e for e in s4["entries"] if e["exercise"] == "Push/Bankdrücken KH (je Hand)"] + sb4 = [e for e in s4["entries"] if e["exercise"] == "Push/Schrägbank KH (je Hand)"] + check("PlanPrefill+AutoReset: letztes Workout gewinnt", + len(bd4) == 3 + and [e["weight"] for e in bd4] == ["18", "18,5", "20"], bd4) + check("PlanPrefill+AutoReset: ohne Verlauf Plan-Gewicht", + len(sb4) == 3 and all(e["weight"] == "14" for e in sb4), sb4) + req("DELETE", f"/api/sessions/{s4['id']}") + + req("PUT", "/api/settings", + {"theme": "dark", "oled": False, "accent": "white", + "autoReset": False, "planPrefill": False, "showTips": True, + "language": "de", "setupDone": False, "onboarded": False, + "weightUnit": "kg", "startTab": "today"}) + st, s = req("GET", "/api/settings") + check("Zurueck auf Standard", s == { + "theme": "dark", "oled": False, "accent": "white", + "autoReset": False, "planPrefill": False, "showTips": True, + "language": "de", "setupDone": False, "onboarded": False, + "weightUnit": "kg", "startTab": "today"}, s) + + finally: + proc.terminate() + proc.wait(timeout=5) + + print(f"\n{PASS} ok, {FAIL} fail") + sys.exit(1 if FAIL else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f946ce2 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Test fuer Mehrbenutzer, Workouts-API und Demo-Import. + +Lauf: python3 tests/test_auth.py +""" +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +import urllib.error + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATE = "/tmp/tp_state3" +PORT = 8277 +BASE_URL = f"http://127.0.0.1:{PORT}" + +PASS = 0 +FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f"FAIL {name} {detail}") + + +def req(method, path, body=None, token=None): + url = BASE_URL + path + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = "Bearer " + token + r = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(r, timeout=10) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode("utf-8")) + except Exception: + return e.code, {} + + +def main(): + if os.path.exists(STATE): + shutil.rmtree(STATE) + os.makedirs(STATE, exist_ok=True) + env = dict(os.environ, TRAINING_STATE_DIR=STATE, PORT=str(PORT)) + proc = subprocess.Popen( + [sys.executable, os.path.join(BASE, "server.py")], + env=env, cwd=BASE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(40): + time.sleep(0.25) + try: + urllib.request.urlopen(BASE_URL + "/api/meta", timeout=1) + break + except Exception: + pass + else: + print("Server nicht erreichbar") + sys.exit(1) + + # --- Offener Modus ------------------------------------------------- + st, meta = req("GET", "/api/meta") + check("offener Modus: auth_required false", + meta["auth_required"] is False and meta["users"] == 0, meta) + st, sess = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"}) + check("offener Modus: Session ohne Token", st == 200, sess) + req("PATCH", f"/api/sessions/{sess['id']}", + {"meta": {"status": "done"}}) + + # --- Registrierung (erster Benutzer) -------------------------------- + st, reg = req("POST", "/api/register", + {"username": "alex", "password": "geheim"}) + check("Registrierung", st == 200 and reg.get("token"), reg) + token_a = reg["token"] + + st, meta = req("GET", "/api/meta") + check("nach Registrierung: auth_required true", + meta["auth_required"] is True and meta["users"] == 1, meta) + + st, me = req("GET", "/api/me", token=token_a) + check("me: username + Owner", me["username"] == "alex" + and me["is_owner"] is True, me) + + st, _ = req("GET", "/api/me") + check("me ohne Token -> 401", st == 401, st) + st, _ = req("GET", "/api/config") + check("config ohne Token -> 401", st == 401, st) + st, _ = req("GET", "/api/sessions") + check("sessions ohne Token -> 401", st == 401, st) + + # Migration: Alt-Daten (offener Modus) gehoeren dem ersten Benutzer + st, done = req("GET", "/api/sessions?status=done", token=token_a) + check("erster Benutzer uebernimmt Alt-Daten", len(done) == 1, done) + + # Zweite Registrierung blockiert + st, _ = req("POST", "/api/register", + {"username": "berta", "password": "geheim"}) + check("zweite Registrierung -> 403", st == 403, st) + + # Login-Fehlerfaelle + st, _ = req("POST", "/api/login", + {"username": "alex", "password": "falsch"}) + check("Login falsches Passwort -> 401", st == 401, st) + st, login = req("POST", "/api/login", + {"username": "alex", "password": "geheim"}) + check("Login korrekt", st == 200 and login.get("token"), login) + token_a2 = login["token"] + st, me = req("GET", "/api/me", token=token_a2) + check("neuer Token gueltig", me["username"] == "alex", me) + + # --- Benutzer-Verwaltung (Owner) ------------------------------------- + st, created = req("POST", "/api/users", + {"username": "berta", "password": "geheim"}, + token=token_a) + check("Owner legt Benutzer an", st == 200 + and created["username"] == "berta", created) + berta_id = created["id"] + st, users = req("GET", "/api/users", token=token_a) + check("Owner listet Benutzer", st == 200 and len(users) == 2, users) + + st, login = req("POST", "/api/login", + {"username": "berta", "password": "geheim"}) + token_b = login["token"] + st, me = req("GET", "/api/me", token=token_b) + check("Berta: nicht Owner", me["username"] == "berta" + and me["is_owner"] is False, me) + st, _ = req("GET", "/api/users", token=token_b) + check("Berta darf keine Benutzer sehen -> 403", st == 403, st) + + # --- Demo-Daten importieren (in Berta, VOR eigener Session) ---------- + demo_path = os.path.join(BASE, "demo-data.json") + demo = json.load(open(demo_path, encoding="utf-8")) + st, imp = req("POST", "/api/import", + {"format": "trainingsplan-export-v2", + "data": json.dumps(demo)}, token=token_b) + check("Demo-Import in Berta", st == 200 and imp["created"] == 22, imp) + + # --- Berta trainiert selbst ------------------------------------------- + st, sess_b = req("POST", "/api/sessions", + {"activity": "gym", "day": "Push"}, token=token_b) + req("PATCH", f"/api/sessions/{sess_b['id']}", + {"sets": [{"exercise": "Push/Bankdrücken KH (je Hand)", + "sort": 0, "weight": "40", "reps": "8", "done": 1}]}, + token=token_b) + req("PATCH", f"/api/sessions/{sess_b['id']}", + {"meta": {"status": "done"}}, token=token_b) + + st, done_b = req("GET", "/api/sessions?status=done", token=token_b) + st, done_a = req("GET", "/api/sessions?status=done", token=token_a) + check("Isolation: B sieht 23 eigene Sessions (22 Demo + 1)", + len(done_b) == 23, len(done_b)) + check("Isolation: A sieht seine 1 Session (nicht B)", + len(done_a) == 1, len(done_a)) + + st, rv = req("GET", "/api/reset-values?exercise=" + + urllib.parse.quote("Push/Bankdrücken KH (je Hand)") + + "&set=0", token=token_b) + check("Reset-Werte sind pro Benutzer (eigene heutige Session)", + rv.get("weight") == "40", rv) + + # Einstellungen pro Benutzer + req("PUT", "/api/settings", {"theme": "light"}, token=token_a) + st, s_a = req("GET", "/api/settings", token=token_a) + st, s_b = req("GET", "/api/settings", token=token_b) + check("Einstellungen pro Benutzer", s_a["theme"] == "light" + and s_b["theme"] == "dark", (s_a, s_b)) + + # Owner setzt Sprache/Akzent -> Server-Standard fuer andere Benutzer + req("PUT", "/api/settings", {"language": "en", "accent": "green"}, + token=token_a) + st, s_b = req("GET", "/api/settings", token=token_b) + check("Owner-Sprache/Akzent wird Server-Standard", + s_b["language"] == "en" and s_b["accent"] == "green", s_b) + req("PUT", "/api/settings", + {"language": "de", "accent": "orange", "theme": "dark"}, + token=token_a) + + # --- Workouts-API ----------------------------------------------------- + st, wo_b = req("GET", "/api/workouts", token=token_b) + check("Workouts-API: 23 Workouts", st == 200 and len(wo_b) == 23, len(wo_b)) + w = wo_b[0] # neueste Session = Berta heute + check("Workouts-API: Shape", w["activity_name"] == "Gym (Kraft)" + and w["day"] == "Push" and w["duration_min"] is not None + and w["exercises"][0]["name"] == "Bankdrücken KH (je Hand)" + and w["exercises"][0]["sets"][0]["weight"] == "40", w) + st, wo_b = req("GET", "/api/workouts?activity=gym", token=token_b) + check("Workouts nach Import (gym): 15 Demo + 1 eigene", len(wo_b) == 16, + len(wo_b)) + st, wo_a = req("GET", "/api/workouts", token=token_a) + check("A bleibt unberuehrt vom Import", len(wo_a) == 1, len(wo_a)) + st, stats = req("GET", "/api/stats?exercise=" + + urllib.parse.quote("Push/Bankdrücken KH (je Hand)"), + token=token_b) + check("Statistik aus Demo-Daten + eigene Session", + st == 200 and len(stats) == 6 + and stats[0]["weight"] < stats[-1]["weight"] + and stats[-1]["weight"] == 40, stats) + + # --- Passwort ändern / zurücksetzen ------------------------------------ + st, _ = req("POST", "/api/password", + {"current_password": "falsch", "new_password": "neu123"}, + token=token_a) + check("Eigenes Passwort: falsches aktuelles -> 400", st == 400, st) + st, _ = req("POST", "/api/password", + {"current_password": "geheim", "new_password": "neu123"}, + token=token_a) + check("Eigenes Passwort aendern", st == 200, st) + st, _ = req("POST", "/api/login", + {"username": "alex", "password": "geheim"}) + check("altes Passwort ungueltig", st == 401, st) + st, login2 = req("POST", "/api/login", + {"username": "alex", "password": "neu123"}) + check("neues Passwort funktioniert", st == 200, st) + token_a = login2["token"] + + st, _ = req("PUT", f"/api/users/{berta_id}/password", + {"password": "neu456"}, token=token_a) + check("Owner setzt Passwort von B", st == 200, st) + st, _ = req("GET", "/api/sessions", token=token_b) + check("B: alter Token nach Passwort-Reset invalidiert", st == 401, st) + st, _ = req("POST", "/api/login", + {"username": "berta", "password": "geheim"}) + check("B: altes Passwort weg", st == 401, st) + st, login_b2 = req("POST", "/api/login", + {"username": "berta", "password": "neu456"}) + check("B: neues Passwort funktioniert", st == 200, st) + token_b = login_b2["token"] + st, _ = req("PUT", f"/api/users/{berta_id}/password", + {"password": "x"}, token=token_a) + check("Passwort zu kurz -> 400", st == 400, st) + st, _ = req("PUT", f"/api/users/{berta_id}/password", + {"password": "neu789"}, token=token_b) + check("Nicht-Owner darf Passwort nicht setzen -> 403", st == 403, st) + + # --- Logout ------------------------------------------------------------- + st, _ = req("POST", "/api/logout", token=token_b) + st, _ = req("GET", "/api/sessions", token=token_b) + check("Logout invalidiert Token -> 401", st == 401, st) + + # --- Benutzer loeschen --------------------------------------------------- + st, r = req("DELETE", f"/api/users/{berta_id}", token=token_a) + check("Owner loescht Benutzer", st == 200 and r["deleted"] == 1, r) + st, login = req("POST", "/api/login", + {"username": "berta", "password": "geheim"}) + check("geloeschter Benutzer kann nicht mehr einloggen", st == 401, st) + st, users = req("GET", "/api/users", token=token_a) + check("Benutzerliste nach Loeschung", len(users) == 1, users) + st, done_a = req("GET", "/api/sessions?status=done", token=token_a) + check("A-Daten nach Loeschung von B intakt", len(done_a) == 1, len(done_a)) + + finally: + proc.terminate() + proc.wait(timeout=5) + + print(f"\n{PASS} ok, {FAIL} fail") + sys.exit(1 if FAIL else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_ui.js b/tests/test_ui.js new file mode 100644 index 0000000..21d1b48 --- /dev/null +++ b/tests/test_ui.js @@ -0,0 +1,269 @@ +/* UI-Integrationstest (jsdom) gegen einen echten Server-Subprozess. + * + * Lauf: node tests/test_ui.js (jsdom muss installierbar sein, z.B. + * NODE_PATH=/pfad/zu/node_modules node tests/test_ui.js) + * + * Der Test laeuft im offenen Modus (frische DB ohne Benutzer). + */ +const path = require("path"); +const BASE = path.join(__dirname, ".."); + +let jsdom; +try { + jsdom = require("jsdom"); +} catch (_) { + console.error("jsdom fehlt. Installieren: npm install jsdom@22.1.0 (z.B. in /tmp/tp_test) und mit NODE_PATH=/node_modules starten."); + process.exit(2); +} +const { JSDOM } = jsdom; + +const { spawn } = require("child_process"); +const fs = require("fs"); +const http = require("http"); + +const STATE = "/tmp/tp_state_ui"; +const PORT = 8233; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +let pass = 0, fail = 0; +let jsErrors = []; +function check(name, cond, detail = "") { + if (cond) { pass++; console.log(" ok " + name); } + else { fail++; console.log("FAIL " + name + " " + detail); } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function waitFor(dom, fn, timeout = 4000) { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + if (fn(dom)) return true; + await sleep(80); + } + return false; +} + +function apiGet(p) { + return new Promise((resolve, reject) => { + http.get(BASE_URL + p, res => { + let d = ""; + res.on("data", c => d += c); + res.on("end", () => resolve(JSON.parse(d))); + }).on("error", reject); + }); +} + +function cleanState() { + fs.rmSync(STATE, { recursive: true, force: true }); + fs.mkdirSync(STATE, { recursive: true }); +} + +async function main() { + cleanState(); + const server = spawn("python3", [path.join(BASE, "server.py")], { + env: { ...process.env, TRAINING_STATE_DIR: STATE, PORT: String(PORT) }, + cwd: BASE, stdio: "ignore", + }); + let window; + try { + for (let i = 0; i < 60; i++) { + try { await apiGet("/api/meta"); break; } + catch (_) { await sleep(200); } + } + + const html = fs.readFileSync(path.join(BASE, "index.html"), "utf8") + .replace(/