Startpunkt 02.10.2026: aktueller Stand des Trainingsplans
Historie bewusst neu aufgesetzt, ein einziger Commit als neuer Ausgangspunkt.
This commit is contained in:
commit
8fc77212cb
18 files changed
+10863
No files matched your search
+17
@@ -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
|
||||||
+19
@@ -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"]
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The 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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -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://<host>: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`.
|
||||||
@@ -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/<id>` | Teil-Update: `{meta: {...}}` und/oder `{sets: [...]}` |
|
||||||
|
| POST | `/api/sessions/<id>/autofill` | "Alles zurücksetzen": Sätze auf letztes Workout setzen |
|
||||||
|
| DELETE | `/api/sessions/<id>` | 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/<id>/password` | Passwort setzen/zurücksetzen `{password}` (nur Owner, invalidiert alte Tokens) |
|
||||||
|
| DELETE | `/api/users/<id>` | Benutzer + Daten löschen (nur Owner, nicht selbst) |
|
||||||
|
|
||||||
|
**PATCH `/api/sessions/<id>`** 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 <token>` 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/<id>/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/<id>/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 `<html>`. 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.
|
||||||
+219
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2767
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>REKORD</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body class="boot">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-name">REKORD</span>
|
||||||
|
<span class="brand-date" id="today-label"></span>
|
||||||
|
</div>
|
||||||
|
<nav class="tabs" aria-label="Ansicht">
|
||||||
|
<button class="tab is-active" data-view="today" type="button">Heute</button>
|
||||||
|
<button class="tab" data-view="history" type="button">Verlauf</button>
|
||||||
|
<button class="tab" data-view="stats" type="button">Statistik</button>
|
||||||
|
<button class="tab" data-view="data" type="button">Daten</button>
|
||||||
|
<button class="tab" data-view="settings" type="button">Einstellungen</button>
|
||||||
|
</nav>
|
||||||
|
<span class="user-chip" id="user-chip" hidden></span>
|
||||||
|
<button class="btn btn-small" id="logout-btn" hidden type="button">Abmelden</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="view-today" class="view is-active"></section>
|
||||||
|
<section id="view-history" class="view"></section>
|
||||||
|
<section id="view-stats" class="view"></section>
|
||||||
|
<section id="view-data" class="view"></section>
|
||||||
|
<section id="view-settings" class="view"></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="setup-gate" class="auth-gate" hidden></div>
|
||||||
|
<div id="auth-gate" class="auth-gate" hidden></div>
|
||||||
|
<div id="overlay-root" class="overlay-root" hidden></div>
|
||||||
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1132
File diff suppressed because it is too large.
Load diff
@@ -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 "<html" in static)
|
||||||
|
st, css = raw("GET", "/styles.css")
|
||||||
|
check("styles.css", st == 200 and "--accent" in css)
|
||||||
|
st, js = raw("GET", "/app.js")
|
||||||
|
check("app.js", st == 200 and "makeModel" in js)
|
||||||
|
|
||||||
|
st, sess = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
|
||||||
|
check("session starten", st == 200 and sess["status"] == "active"
|
||||||
|
and sess["day"] == "Push" and sess["start_time"], sess)
|
||||||
|
sid = sess["id"]
|
||||||
|
|
||||||
|
st, act = req("GET", "/api/sessions?status=active")
|
||||||
|
check("aktive Session sichtbar", st == 200 and len(act) == 1)
|
||||||
|
|
||||||
|
st, done = req("GET", "/api/sessions?status=done")
|
||||||
|
check("kein Geist im Verlauf (vor Abschluss)", st == 200 and len(done) == 0)
|
||||||
|
|
||||||
|
setsA = [
|
||||||
|
{"exercise": ex["key"], "sort": 0, "weight": "18", "reps": "10", "done": 1},
|
||||||
|
{"exercise": ex["key"], "sort": 1, "weight": "18,5", "reps": "8", "done": 1},
|
||||||
|
{"exercise": ex["key"], "sort": 2, "weight": "20", "reps": "6", "done": 0},
|
||||||
|
]
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsA})
|
||||||
|
check("sets Uebung A schreiben", st == 200, r)
|
||||||
|
entriesA = r["entries"]
|
||||||
|
check("3 Sets gespeichert", len(entriesA) == 3, entriesA)
|
||||||
|
idsA = [e["id"] for e in entriesA]
|
||||||
|
check("stabile IDs", all(x for x in idsA))
|
||||||
|
check("Komma-Gewicht bleibt erhalten",
|
||||||
|
next(e for e in entriesA if e["sort"] == 1)["weight"] == "18,5")
|
||||||
|
|
||||||
|
setsA2 = [
|
||||||
|
{"exercise": ex["key"], "sort": 0, "weight": "18", "reps": "10", "done": 1},
|
||||||
|
{"exercise": ex["key"], "sort": 1, "weight": "20", "reps": "8", "done": 1},
|
||||||
|
{"exercise": ex["key"], "sort": 2, "weight": "20", "reps": "6", "done": 0},
|
||||||
|
]
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsA2})
|
||||||
|
idsA2 = [e["id"] for e in r["entries"]]
|
||||||
|
check("Upsert haelt IDs stabil", idsA2 == idsA, (idsA, idsA2))
|
||||||
|
|
||||||
|
exB = push["exercises"][1]
|
||||||
|
setsB = [{"exercise": exB["key"], "sort": 0, "weight": "14", "reps": "12", "done": 0}]
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsB})
|
||||||
|
eA = next(e for e in r["entries"] if e["exercise"] == ex["key"] and e["sort"] == 1)
|
||||||
|
eB = next(e for e in r["entries"] if e["exercise"] == exB["key"])
|
||||||
|
check("Uebung A unberuehrt (ID + Werte)", eA["id"] == idsA2[1]
|
||||||
|
and eA["weight"] == "20" and eA["reps"] == "8", eA)
|
||||||
|
check("Uebung B angelegt", eB["weight"] == "14", eB)
|
||||||
|
|
||||||
|
setsA3 = [
|
||||||
|
{"exercise": ex["key"], "sort": 0, "weight": "18", "reps": "10", "done": 1},
|
||||||
|
{"exercise": ex["key"], "sort": 1, "weight": "20", "reps": "8", "done": 1},
|
||||||
|
]
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsA3})
|
||||||
|
check("Set 3 entfernt", len([e for e in r["entries"]
|
||||||
|
if e["exercise"] == ex["key"]]) == 2)
|
||||||
|
check("Uebung B nach Entfernen unberuehrt",
|
||||||
|
any(e["exercise"] == exB["key"] for e in r["entries"]))
|
||||||
|
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid}",
|
||||||
|
{"meta": {"note": "gutes Training", "status": "done"}})
|
||||||
|
check("abschliessen setzt Endzeit", r["end_time"] != ""
|
||||||
|
and r["status"] == "done" and r["note"] == "gutes Training", r)
|
||||||
|
|
||||||
|
st, done = req("GET", "/api/sessions?status=done")
|
||||||
|
check("Session sofort im Verlauf", st == 200 and len(done) == 1)
|
||||||
|
|
||||||
|
st, act = req("GET", "/api/sessions?status=active")
|
||||||
|
check("nicht mehr aktiv", len(act) == 0)
|
||||||
|
|
||||||
|
st, rv = req("GET", f"/api/reset-values?exercise={urllib.parse.quote(ex['key'])}&set=1")
|
||||||
|
check("reset-values liefert letzte Messung", rv.get("weight") == "20", rv)
|
||||||
|
|
||||||
|
st, sess2 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
|
||||||
|
sid2 = sess2["id"]
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid2}",
|
||||||
|
{"sets": [{"exercise": ex["key"], "sort": 1, "weight": "99", "reps": "5", "done": 1}]})
|
||||||
|
st, rv = req("GET", f"/api/reset-values?exercise={urllib.parse.quote(ex['key'])}&set=1")
|
||||||
|
check("Reset ignoriert heute-laufende Session",
|
||||||
|
rv.get("weight") == "20", rv)
|
||||||
|
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{sid}",
|
||||||
|
{"meta": {"date": "2026-09-20", "start_time": "17:30",
|
||||||
|
"end_time": "18:40"}})
|
||||||
|
check("Verlauf: Datum/Zeit editierbar",
|
||||||
|
r["date"] == "2026-09-20" and r["start_time"] == "17:30"
|
||||||
|
and r["end_time"] == "18:40", r)
|
||||||
|
|
||||||
|
st, cs = req("POST", "/api/sessions", {"activity": "run"})
|
||||||
|
cid = cs["id"]
|
||||||
|
st, r = req("PATCH", f"/api/sessions/{cid}",
|
||||||
|
{"meta": {"fields": {"distanceKm": "5,2", "minutes": "32",
|
||||||
|
"pacePerKm": "6:09", "heartRate": "148",
|
||||||
|
"elevation": "45"},
|
||||||
|
"status": "done"}})
|
||||||
|
check("Cardio-Felder gespeichert",
|
||||||
|
r["fields"]["distanceKm"] == "5,2" and r["fields"]["pacePerKm"] == "6:09", r["fields"])
|
||||||
|
|
||||||
|
st, prev = req("POST", "/api/sessions",
|
||||||
|
{"activity": "gym", "day": "Push", "date": "2026-09-15"})
|
||||||
|
pid = prev["id"]
|
||||||
|
req("PATCH", f"/api/sessions/{pid}",
|
||||||
|
{"sets": [{"exercise": ex["key"], "sort": 0, "weight": "22", "reps": "8", "done": 1}],
|
||||||
|
"meta": {"status": "done"}})
|
||||||
|
st, stats = req("GET", f"/api/stats?exercise={urllib.parse.quote(ex['key'])}")
|
||||||
|
check("Statistik: 2 Termine", st == 200 and len(stats) == 2, stats)
|
||||||
|
s0, s1 = stats[0], stats[1]
|
||||||
|
check("Statistik: Delta zur ersten Messung",
|
||||||
|
s0["delta"] == 0 and abs(s1["delta"]) > 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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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=<dort>/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(/<script src="\/app.js"><\/script>/, "");
|
||||||
|
const appJs = fs.readFileSync(path.join(BASE, "app.js"), "utf8");
|
||||||
|
|
||||||
|
const dom = new JSDOM(html, { runScripts: "dangerously", url: BASE_URL + "/" });
|
||||||
|
window = dom.window;
|
||||||
|
window.errors = [];
|
||||||
|
jsErrors = window.errors;
|
||||||
|
window.addEventListener("error", e => window.errors.push(e.message || String(e)));
|
||||||
|
window.fetch = (url, opts) => fetch(BASE_URL + url, opts);
|
||||||
|
window.eval(appJs);
|
||||||
|
|
||||||
|
check("init: 3 Aktivitaets-Karten", await waitFor(dom, d =>
|
||||||
|
d.window.document.querySelectorAll(".act-card").length === 3));
|
||||||
|
|
||||||
|
// --- Ersteinrichtungs-Wizard abschliessen (Einzelmodus) ----------------
|
||||||
|
const gate = () => window.document.querySelector("#setup-gate");
|
||||||
|
await waitFor(dom, d => !d.window.document.querySelector("#setup-gate").hidden);
|
||||||
|
check("Setup: Sprache kommt zuerst", await waitFor(dom, d =>
|
||||||
|
gate().querySelector(".wizard-title")
|
||||||
|
&& gate().querySelector(".wizard-title").textContent === "Sprache festlegen"));
|
||||||
|
check("Setup: Fortschrittsleiste oben",
|
||||||
|
gate().querySelectorAll(".wp-step").length === 3
|
||||||
|
&& gate().querySelectorAll(".wp-step.is-current").length === 1
|
||||||
|
&& gate().querySelectorAll(".wp-step.is-done").length === 0);
|
||||||
|
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Weiter").click();
|
||||||
|
await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2);
|
||||||
|
check("Setup: Fortschritt aktualisiert",
|
||||||
|
gate().querySelectorAll(".wp-step.is-done").length === 1
|
||||||
|
&& gate().querySelectorAll(".wp-step.is-current").length === 1);
|
||||||
|
const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option"));
|
||||||
|
check("Setup: zwei gleichwertige Optionen",
|
||||||
|
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
|
||||||
|
&& setupOptions().some(b => b.textContent.includes("Einzelmodus")));
|
||||||
|
setupOptions().find(b => b.textContent.includes("Einzelmodus")).click();
|
||||||
|
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Weiter").click();
|
||||||
|
await waitFor(dom, d => gate().querySelector(".accent-row"));
|
||||||
|
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Einrichtung abschließen").click();
|
||||||
|
check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d =>
|
||||||
|
d.window.document.querySelector("#setup-gate").hidden));
|
||||||
|
|
||||||
|
const gymCard = () => window.document.querySelectorAll(".act-card")[0];
|
||||||
|
gymCard().querySelector(".btn-primary").click();
|
||||||
|
check("gym: Session gestartet", await waitFor(dom, d =>
|
||||||
|
d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".set-row").length > 0));
|
||||||
|
|
||||||
|
const firstBlock = () => gymCard().querySelector(".ex-block");
|
||||||
|
const chk1 = () => firstBlock().querySelectorAll(".set-check")[0];
|
||||||
|
check("Set-Button mit Label 'Set' + Box",
|
||||||
|
chk1().querySelector(".set-check-label").textContent === "Set"
|
||||||
|
&& !!chk1().querySelector(".set-check-box"));
|
||||||
|
check("Set-Button: Box leer (nicht abgehakt)", chk1().querySelector(".set-check-box").textContent === "");
|
||||||
|
firstBlock().querySelectorAll(".set-check")[2].click();
|
||||||
|
check("Set-Kette: 1..3 aktiv mit Haken", await waitFor(dom, d => {
|
||||||
|
const c = d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".set-check");
|
||||||
|
return c[0].classList.contains("is-done") && c[2].classList.contains("is-done")
|
||||||
|
&& !c[3].classList.contains("is-done")
|
||||||
|
&& c[0].querySelector(".set-check-box").textContent === "✓";
|
||||||
|
}));
|
||||||
|
const wIn = () => firstBlock().querySelectorAll(".set-row")[0].querySelector(".field-input");
|
||||||
|
wIn().value = "18,5";
|
||||||
|
wIn().dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||||
|
const wBefore = wIn().value;
|
||||||
|
chk1().click();
|
||||||
|
check("Set-Klick veraendert Gewicht nicht", wIn().value === wBefore);
|
||||||
|
firstBlock().querySelectorAll(".set-check")[2].click();
|
||||||
|
|
||||||
|
const picker = () => gymCard().querySelector(".ex-picker");
|
||||||
|
const pullEx = "Pull/Latzug / Klimmzug-Maschine";
|
||||||
|
picker().value = pullEx;
|
||||||
|
picker().dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||||
|
check("Uebung aus anderem Tag hinzugefuegt", await waitFor(dom, d => {
|
||||||
|
const blocks = d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".ex-block");
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
return last.querySelector(".ex-name").textContent === "Latzug / Klimmzug-Maschine";
|
||||||
|
}));
|
||||||
|
const blocks = () => gymCard().querySelectorAll(".ex-block");
|
||||||
|
const addedBlock = blocks()[blocks().length - 1];
|
||||||
|
const rmBtn = addedBlock.querySelector(".ex-remove");
|
||||||
|
check("Entfernen-Knopf fuer hinzugefuegte Uebung", !!rmBtn);
|
||||||
|
rmBtn.click();
|
||||||
|
check("Uebung entfernt", await waitFor(dom, d =>
|
||||||
|
!Array.from(d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".ex-block"))
|
||||||
|
.some(b => b.querySelector(".ex-name").textContent === "Latzug / Klimmzug-Maschine")));
|
||||||
|
|
||||||
|
gymCard().querySelector(".session-head .btn-primary").click();
|
||||||
|
check("abschliessen: Karte zurueck auf Start", await waitFor(dom, d => {
|
||||||
|
const c = d.window.document.querySelectorAll(".act-card")[0];
|
||||||
|
return !!c.querySelector(".start-box");
|
||||||
|
}));
|
||||||
|
let done = await apiGet("/api/sessions?status=done");
|
||||||
|
check("Verlauf (API): 1 abgeschlossene Session", done.length === 1);
|
||||||
|
|
||||||
|
window.document.querySelector('.tab[data-view="history"]').click();
|
||||||
|
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length === 1);
|
||||||
|
const histCard = () => window.document.querySelector(".hist-card");
|
||||||
|
check("Verlauf: Haken-Box zeigt Abhak-Status",
|
||||||
|
!!histCard().querySelector(".set-done-box")
|
||||||
|
&& histCard().querySelector(".set-done-box").textContent === "✓");
|
||||||
|
check("Verlauf: Abgehaktes Set mit Gewicht", await waitFor(dom, d =>
|
||||||
|
d.window.document.querySelector(".hist-sets").textContent.includes("18,5")));
|
||||||
|
|
||||||
|
window.document.querySelector('.tab[data-view="settings"]').click();
|
||||||
|
await waitFor(dom, d => !!d.window.document.querySelector(".accent-row"));
|
||||||
|
check("Einstellungen: Theme-Buttons",
|
||||||
|
Array.from(window.document.querySelectorAll("#view-settings .day-picker .day-btn"))
|
||||||
|
.filter(b => ["Dunkel", "System", "Hell"].includes(b.textContent)).length === 3);
|
||||||
|
check("Einstellungen: Akzent-Swatches", window.document.querySelectorAll(".accent-swatch").length >= 5);
|
||||||
|
check("Einstellungen: Toggles + Selects",
|
||||||
|
window.document.querySelectorAll("#view-settings .toggle-input").length === 4
|
||||||
|
&& window.document.querySelectorAll("#view-settings .settings-select-row").length === 2);
|
||||||
|
|
||||||
|
const themeBtns = () => Array.from(window.document.querySelectorAll("#view-settings .day-picker .day-btn"));
|
||||||
|
// OLED-Toggle bei dunklem Theme -> reines Schwarz
|
||||||
|
Array.from(window.document.querySelectorAll("#view-settings .toggle-row"))
|
||||||
|
.find(r => r.querySelector("strong").textContent === "OLED-Dark")
|
||||||
|
.querySelector(".toggle-input").click();
|
||||||
|
await waitFor(dom, d => d.window.document.documentElement.dataset.theme === "oled");
|
||||||
|
check("OLED-Toggle: dunkles Theme wird pure black",
|
||||||
|
window.document.documentElement.dataset.theme === "oled");
|
||||||
|
themeBtns().find(b => b.textContent === "Hell").click();
|
||||||
|
await waitFor(dom, d => d.window.document.documentElement.dataset.theme === "light");
|
||||||
|
check("Theme 'Hell' gesetzt", window.document.documentElement.dataset.theme === "light");
|
||||||
|
|
||||||
|
const swatches = () => Array.from(window.document.querySelectorAll(".accent-swatch"));
|
||||||
|
swatches().find(s => s.getAttribute("aria-label") === "Akzent green").click();
|
||||||
|
await waitFor(dom, d => d.window.document.documentElement.dataset.accent === "green");
|
||||||
|
check("Akzent 'green' gesetzt", window.document.documentElement.dataset.accent === "green");
|
||||||
|
|
||||||
|
const toggles = () => Array.from(window.document.querySelectorAll(".toggle-row"));
|
||||||
|
const tipRow = toggles().find(r => r.querySelector("strong").textContent === "Übungs-Hinweise");
|
||||||
|
tipRow.querySelector(".toggle-input").click();
|
||||||
|
await waitFor(dom, d => d.window.document.querySelector("#view-today").classList.contains("no-tips"));
|
||||||
|
check("showTips aus: no-tips Klasse", window.document.querySelector("#view-today").classList.contains("no-tips"));
|
||||||
|
|
||||||
|
const resetRow = toggles().find(r => r.querySelector("strong").textContent === "Letztes Training vorbefüllen");
|
||||||
|
resetRow.querySelector(".toggle-input").click();
|
||||||
|
// Auf Server-Zustand warten (DOM-Check war falsch: [0] ist der OLED-Toggle)
|
||||||
|
let arOk = false;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
if ((await apiGet("/api/settings")).autoReset === true) { arOk = true; break; }
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
check("Einstellung 'Letztes Training' serverseitig aktiv", arOk);
|
||||||
|
|
||||||
|
window.document.querySelector('.tab[data-view="today"]').click();
|
||||||
|
gymCard().querySelector(".btn-primary").click();
|
||||||
|
// Auto-Reset serverseitig pruefen (deterministisch statt DOM-Timing)
|
||||||
|
let autoOk = false;
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
const act = await apiGet("/api/sessions?status=active");
|
||||||
|
if (act.length && act[0].entries.length === 1
|
||||||
|
&& act[0].entries[0].weight === "18,5") { autoOk = true; break; }
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
check("Auto-Reset: 1 gefuellter Satz geladen", autoOk);
|
||||||
|
await waitFor(dom, d => d.window.document.querySelectorAll(".act-card")[0]
|
||||||
|
.querySelectorAll(".set-row").length > 0, 9000);
|
||||||
|
check("Auto-Reset: Satz nicht abgehakt",
|
||||||
|
gymCard().querySelector(".ex-block .set-check").querySelector(".set-check-box").textContent === "");
|
||||||
|
check("Auto-Reset: 'Alles zuruecksetzen'-Knopf da",
|
||||||
|
!!gymCard().querySelector(".session-head .btn[title*='letzte']"));
|
||||||
|
check("Hinweiszeile ausgeblendet",
|
||||||
|
!gymCard().querySelector(".ex-block .ex-hint"));
|
||||||
|
|
||||||
|
// --- Uebungen per Drag & Drop sortieren --------------------------------
|
||||||
|
gymCard().querySelectorAll(".ex-block")[0]
|
||||||
|
.dispatchEvent(new window.Event("dragstart", { bubbles: true }));
|
||||||
|
const target = gymCard().querySelectorAll(".ex-block")[1];
|
||||||
|
target.dispatchEvent(new window.Event("drop", { bubbles: true }));
|
||||||
|
check("Uebungen: Drag & Drop sortiert",
|
||||||
|
Array.from(gymCard().querySelectorAll(".ex-block"))[0]
|
||||||
|
.querySelector(".ex-name").textContent !== "Bankdrücken KH (je Hand)");
|
||||||
|
|
||||||
|
// --- Wochen-Uebersicht ---------------------------------------------------
|
||||||
|
window.document.querySelector('.tab[data-view="history"]').click();
|
||||||
|
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length >= 1);
|
||||||
|
Array.from(window.document.querySelectorAll(".day-picker .day-btn"))
|
||||||
|
.find(b => b.textContent === "Woche").click();
|
||||||
|
check("Wochen-Uebersicht: Raster mit 7 Tagen",
|
||||||
|
await waitFor(dom, d => d.window.document.querySelectorAll(".week-day").length === 7));
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
if (jsErrors && jsErrors.length) {
|
||||||
|
console.log("JS-Fehler im Browser:", jsErrors.slice(0, 5).join(" | "));
|
||||||
|
fail += jsErrors.length;
|
||||||
|
}
|
||||||
|
console.log(`\n${pass} ok, ${fail} fail`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/* UI-Test Auth-Gate (jsdom) gegen einen echten Server.
|
||||||
|
*
|
||||||
|
* Lauf: NODE_PATH=<...>/node_modules node tests/test_ui_auth.js
|
||||||
|
*/
|
||||||
|
const path = require("path");
|
||||||
|
const BASE = path.join(__dirname, "..");
|
||||||
|
const { JSDOM } = require("jsdom");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
const STATE = "/tmp/tp_state_ui_auth";
|
||||||
|
const PORT = 8237;
|
||||||
|
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
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));
|
||||||
|
|
||||||
|
function api(method, p, body, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = body ? JSON.stringify(body) : null;
|
||||||
|
const req = http.request(BASE_URL + p, {
|
||||||
|
method,
|
||||||
|
headers: Object.assign(data ? {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(data),
|
||||||
|
} : {}, token ? { Authorization: "Bearer " + token } : {}),
|
||||||
|
}, res => {
|
||||||
|
let d = "";
|
||||||
|
res.on("data", c => d += c);
|
||||||
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
|
||||||
|
});
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApp(html, appJs, token) {
|
||||||
|
const dom = new JSDOM(html, { runScripts: "dangerously", url: BASE_URL + "/" });
|
||||||
|
const { window } = dom;
|
||||||
|
window.errors = [];
|
||||||
|
window.addEventListener("error", e => window.errors.push(e.message || String(e)));
|
||||||
|
window.fetch = (url, opts) => fetch(BASE_URL + url, opts);
|
||||||
|
if (token) window.localStorage.setItem("tp_token", token);
|
||||||
|
window.eval(appJs);
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.rmSync(STATE, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(STATE, { recursive: true });
|
||||||
|
const server = spawn("python3", [path.join(BASE, "server.py")], {
|
||||||
|
env: { ...process.env, TRAINING_STATE_DIR: STATE, PORT: String(PORT) },
|
||||||
|
cwd: BASE, stdio: "ignore",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try { await api("GET", "/api/meta"); break; }
|
||||||
|
catch (_) { await sleep(200); }
|
||||||
|
}
|
||||||
|
const html = fs.readFileSync(path.join(BASE, "index.html"), "utf8")
|
||||||
|
.replace(/<script src="\/app.js"><\/script>/, "");
|
||||||
|
const appJs = fs.readFileSync(path.join(BASE, "app.js"), "utf8");
|
||||||
|
|
||||||
|
// --- Szenario 0: Ersteinrichtungs-Wizard (offener Modus) ---------------
|
||||||
|
const winSetup = await loadApp(html, appJs, null);
|
||||||
|
await sleep(1000);
|
||||||
|
const sgate = () => winSetup.document.querySelector("#setup-gate");
|
||||||
|
check("Setup: Gate im offenen Modus sichtbar",
|
||||||
|
!!sgate() && !sgate().hidden);
|
||||||
|
check("Setup: Sprache kommt zuerst",
|
||||||
|
sgate().querySelector(".wizard-title")
|
||||||
|
&& sgate().querySelector(".wizard-title").textContent === "Sprache festlegen");
|
||||||
|
Array.from(sgate().querySelectorAll(".day-btn"))
|
||||||
|
.find(b => b.textContent === "English").click();
|
||||||
|
await sleep(200);
|
||||||
|
check("Setup: Wizard wechselt auf Englisch",
|
||||||
|
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.some(b => b.textContent === "Next"));
|
||||||
|
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Next").click();
|
||||||
|
await sleep(200);
|
||||||
|
const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option"));
|
||||||
|
check("Setup: zwei gleichwertige Optionen",
|
||||||
|
setupOptions().some(b => b.textContent.includes("Multi-user mode"))
|
||||||
|
&& setupOptions().some(b => b.textContent.includes("Single-user mode")));
|
||||||
|
check("Setup: Mehrbenutzer-Infotext",
|
||||||
|
setupOptions().some(b => b.textContent.includes("administrator")
|
||||||
|
&& b.textContent.toLowerCase().includes("single user")));
|
||||||
|
setupOptions().find(b => b.textContent.includes("Multi-user mode")).click();
|
||||||
|
await sleep(150);
|
||||||
|
check("Setup: Formular nach Mehrbenutzermodus-Klick",
|
||||||
|
!!sgate().querySelector("input#setup-user")
|
||||||
|
&& !!sgate().querySelector("input#setup-pass"));
|
||||||
|
// Einzelmodus waehlen -> Weiter -> Darstellung -> abschliessen
|
||||||
|
setupOptions().find(b => b.textContent.includes("Single-user mode")).click();
|
||||||
|
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Next").click();
|
||||||
|
await sleep(200);
|
||||||
|
check("Setup: Darstellung-Schritt (Theme + Akzent)",
|
||||||
|
sgate().querySelector(".wizard-title").textContent === "Appearance"
|
||||||
|
&& !!sgate().querySelector(".accent-row")
|
||||||
|
&& !!sgate().querySelector(".toggle-row"));
|
||||||
|
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Finish setup").click();
|
||||||
|
await sleep(400);
|
||||||
|
check("Setup: Gate zu + setupDone + Sprache persistiert",
|
||||||
|
sgate().hidden
|
||||||
|
&& (await api("GET", "/api/settings")).body.setupDone === true
|
||||||
|
&& (await api("GET", "/api/settings")).body.language === "en");
|
||||||
|
|
||||||
|
const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" });
|
||||||
|
check("API: Register", reg.status === 200 && !!reg.body.token, reg.body);
|
||||||
|
|
||||||
|
// --- Szenario 1: ohne Token -> Auth-Gate, Inline-Fehler, Login in-place ---
|
||||||
|
const winNoToken = await loadApp(html, appJs, null);
|
||||||
|
await sleep(1200);
|
||||||
|
const gate = () => winNoToken.document.querySelector("#auth-gate");
|
||||||
|
check("ohne Token: Auth-Gate sichtbar",
|
||||||
|
gate() && !gate().hidden && winNoToken.document.body.classList.contains("no-auth"));
|
||||||
|
check("ohne Token: Login-Formular + Felder",
|
||||||
|
!!gate().querySelector('input#auth-user')
|
||||||
|
&& !!gate().querySelector('input#auth-pass')
|
||||||
|
&& !!gate().querySelector(".btn-primary"));
|
||||||
|
check("ohne Token: Login-Screen in Server-Sprache (en)",
|
||||||
|
Array.from(gate().querySelectorAll(".btn"))
|
||||||
|
.some(b => b.textContent === "Log in")
|
||||||
|
&& gate().querySelector(".auth-sub").textContent.includes("Log in to see"));
|
||||||
|
// falsches Kennwort -> Inline-Fehler
|
||||||
|
gate().querySelector('input#auth-user').value = "alex";
|
||||||
|
gate().querySelector('input#auth-pass').value = "falsch";
|
||||||
|
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
|
||||||
|
await sleep(500);
|
||||||
|
const authErr = gate().querySelector(".auth-error");
|
||||||
|
check("ohne Token: Inline-Fehler bei falschem Kennwort",
|
||||||
|
authErr && !authErr.hidden
|
||||||
|
&& authErr.textContent.includes("Incorrect username or password"),
|
||||||
|
authErr && authErr.textContent);
|
||||||
|
// korrektes Kennwort -> Login wechselt OHNE Reload in-place
|
||||||
|
gate().querySelector('input#auth-pass').value = "geheim";
|
||||||
|
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
|
||||||
|
let loggedIn = false;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const og = winNoToken.document.querySelector("#setup-gate");
|
||||||
|
if (og && !og.hidden && winNoToken.document.body.classList.contains("no-auth")) {
|
||||||
|
loggedIn = true; // Onboarding-Gate erscheint -> Anmeldung war erfolgreich
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (winNoToken.document.querySelectorAll(".act-card").length === 3
|
||||||
|
&& winNoToken.document.querySelector("#user-chip").textContent === "alex") {
|
||||||
|
loggedIn = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn);
|
||||||
|
// Onboarding abschliessen (im selben Fenster)
|
||||||
|
const ogate = () => winNoToken.document.querySelector("#setup-gate");
|
||||||
|
if (ogate() && !ogate().hidden) {
|
||||||
|
Array.from(ogate().querySelectorAll(".day-btn"))
|
||||||
|
.find(b => b.textContent === "German").click();
|
||||||
|
await sleep(150);
|
||||||
|
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Weiter").click();
|
||||||
|
await sleep(200);
|
||||||
|
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
|
||||||
|
.find(b => b.textContent === "Einrichtung abschließen").click();
|
||||||
|
await sleep(400);
|
||||||
|
}
|
||||||
|
check("Login: Onboarding abgeschlossen, Heute-Ansicht sichtbar",
|
||||||
|
ogate() && ogate().hidden
|
||||||
|
&& winNoToken.document.querySelectorAll(".act-card").length === 3
|
||||||
|
&& winNoToken.document.querySelector("#user-chip").textContent === "alex");
|
||||||
|
check("Login: Abmelden-Knopf sichtbar",
|
||||||
|
!winNoToken.document.querySelector("#logout-btn").hidden);
|
||||||
|
check("Login: kein Auth-Gate mehr",
|
||||||
|
winNoToken.document.querySelector("#auth-gate").hidden);
|
||||||
|
|
||||||
|
// --- Szenario 2: frisches Fenster mit Token -> direkt eingeloggt ------
|
||||||
|
const winTok = await loadApp(html, appJs, reg.body.token);
|
||||||
|
await sleep(1200);
|
||||||
|
let tokOk = false;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
if (winTok.document.querySelectorAll(".act-card").length === 3
|
||||||
|
&& winTok.document.querySelector("#user-chip").textContent === "alex") {
|
||||||
|
tokOk = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
check("mit Token: Heute-Ansicht geladen (kein Onboarding mehr)", tokOk);
|
||||||
|
check("mit Token: kein Auth-Gate", winTok.document.querySelector("#auth-gate").hidden);
|
||||||
|
|
||||||
|
// --- Szenario 3: Einstellungen mit Konto- und Benutzer-Bereich --------
|
||||||
|
winTok.document.querySelector('.tab[data-view="settings"]').click();
|
||||||
|
let secTitles = [];
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
secTitles = Array.from(winTok.document.querySelectorAll(".data-section h3"))
|
||||||
|
.map(h => h.textContent);
|
||||||
|
if (secTitles.includes("Konto") && secTitles.includes("Benutzer")) break;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
check("Einstellungen: Konto-Bereich (Kennwort aendern)",
|
||||||
|
secTitles.includes("Konto")
|
||||||
|
&& Array.from(winTok.document.querySelectorAll(".data-section"))
|
||||||
|
.some(s => s.querySelector("h3").textContent === "Konto"
|
||||||
|
&& !!s.querySelector('input[placeholder="Aktuelles Kennwort"]')
|
||||||
|
&& !!s.querySelector('input[placeholder="Neues Kennwort"]')));
|
||||||
|
check("Einstellungen: nur ein Benutzer -> Hinweis statt Liste", await (async () => {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (Array.from(winTok.document.querySelectorAll(".user-row"))
|
||||||
|
.some(r => r.textContent.includes("einzige"))) return true;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})());
|
||||||
|
// Zweiten Benutzer anlegen -> Liste zeigt ihn, Admin bleibt draussen
|
||||||
|
const createdB = await api("POST", "/api/users",
|
||||||
|
{ username: "berta", password: "geheim" }, reg.body.token);
|
||||||
|
check("API: Owner legt Berta an", createdB.status === 200, createdB.body);
|
||||||
|
winTok.document.querySelector('.tab[data-view="today"]').click();
|
||||||
|
winTok.document.querySelector('.tab[data-view="settings"]').click();
|
||||||
|
await sleep(400);
|
||||||
|
const rows = () => Array.from(winTok.document.querySelectorAll(".user-row"));
|
||||||
|
check("Einstellungen: Berta in Liste, Admin nicht aufgelistet",
|
||||||
|
rows().length === 1 && rows()[0].textContent.includes("berta")
|
||||||
|
&& !rows().some(r => r.textContent.includes("alex")));
|
||||||
|
check("Einstellungen: Kennwort-Knopf pro Benutzer",
|
||||||
|
Array.from(rows()[0].querySelectorAll(".btn"))
|
||||||
|
.some(b => b.textContent === "Kennwort ändern"));
|
||||||
|
|
||||||
|
// --- Szenario 4: API-Login-Faelle --------------------------------------
|
||||||
|
check("API: Login mit falschem Passwort -> 401",
|
||||||
|
(await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);
|
||||||
|
check("API: Login korrekt -> Token",
|
||||||
|
(await api("POST", "/api/login", { username: "alex", password: "geheim" })).status === 200);
|
||||||
|
|
||||||
|
const errs = winNoToken.errors.concat(winTok.errors);
|
||||||
|
if (errs.length) {
|
||||||
|
console.log("JS-Fehler:", errs.slice(0, 5).join(" | "));
|
||||||
|
fail += errs.length;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
console.log(`\n${pass} ok, ${fail} fail`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Trainingsplan - Neubau-Spezifikation
|
||||||
|
|
||||||
|
Ziel fuer den Agenten: einen selbstgehosteten Trainingsverlaufs-Tracker von NULL neu bauen.
|
||||||
|
Nicht den alten Code reparieren. Das Konzept verstehen und besser umsetzen.
|
||||||
|
Es gibt KEINen bestehenden Code gestern. Alles folgt dieser Beschreibung + Wahrnehmung bei Bedarf.
|
||||||
|
|
||||||
|
## Auftrag
|
||||||
|
|
||||||
|
Baue eine schlanke Web-App fuer den taeglichen Gang ins Fitnessstudio.
|
||||||
|
Eine Person, ein Server, keine Cloud. Fokus: mit minimaler Reibung erfassen,
|
||||||
|
was trainiert wurde, und ueber Wochen erkennen, ob man staerker wird.
|
||||||
|
|
||||||
|
Aktivitaeten: **Gym (Kraft)**, **Laufen**, **Radfahren**. Die App lebt auf dem Server
|
||||||
|
(daten liegen serverseitig auf der Platte, nicht im Browser-Cache).
|
||||||
|
|
||||||
|
## Wichtigste Prinzipien (nicht verhandelbar)
|
||||||
|
|
||||||
|
1. **Konfiguration statt Code.** Der Trainingsplan (Uebungen, Saeetze, Reps, Gewichte,
|
||||||
|
Maschinen, Erklaerungen) liegt in einer Config-Datei (`config.json`). Keine
|
||||||
|
Uebungsliste hart im Code. Ein Nicht-Programmierer aendert den Plan in der Config.
|
||||||
|
2. **Serverseitige Persistenz.** Daten in SQLite. Mehrere Sessions am Tag, editierbarer
|
||||||
|
und loeschbarer Verlauf. Ueberlebt Neustart.
|
||||||
|
3. **Zustand strikt isolieren.** Eine Aenderung in Uebung A fasst nie Uebung B an.
|
||||||
|
Keine flachen Kopien, keine globalen Re-Builds. Jedes Set hat einen stabilen Identifier.
|
||||||
|
4. **Kein Datenverlust.** Waehrend des Tippens keine Re-Renders, die Fokus/Cursor killen.
|
||||||
|
Leer eingeben = leer, nicht 0. Eingaben werden erst gespeichert, wenn's Sinn ergibt.
|
||||||
|
5. **Keine Geister-Eintraege.** Es entsteht kein Verlaufseintrag, solange keine Session
|
||||||
|
wirklich gestartet oder explizit gespeichert wurde.
|
||||||
|
6. **Sichtbare Einheiten.** Ueberall "kg", "km", "min" sichtbar, nie nackte Zahlen.
|
||||||
|
7. **Sets modular.** Pro Uebung Anzahl und Gewicht der Saeetze konfigurierbar,
|
||||||
|
auch einzelne Sets individuell (z.B. Set-Array mit Gewicht/Reps je Set).
|
||||||
|
Hinzufuegen/Entfernen von Sets im UI moeglich.
|
||||||
|
8. **Portabel + Docker.** Laeuft per `python3` (nur Standardbibliothek) und als Docker-
|
||||||
|
Container. Daten-Verzeichnis per Environment-Variable (`TRAINING_STATE_DIR`).
|
||||||
|
Bestehende Daten- und Repo-Config nicht zerstoeren.
|
||||||
|
|
||||||
|
## Funktionalitaet (vollstaendig)
|
||||||
|
|
||||||
|
### Heutige Ansicht
|
||||||
|
- Zyklus Push/Pull/Legs (oder frei konfigurierbar in der Config).
|
||||||
|
- Jede Uebung als Liste ihrer Saeetze.
|
||||||
|
- Pro Satz: **Set-Nummer (klickbar als Abhak-Status), Gewicht, Reps** als Eingabe.
|
||||||
|
- Eingabe als **eine zusammengehoerige Box**: Zahl links, Einheit ("kg"/"reps") als
|
||||||
|
festes Suffix rechts IN derselben Box. Klick auf die ganze Box fokussiert den Input.
|
||||||
|
- **Set-Kette**: Set 3 waehlen aktiviert 1..3 (keine Luecken). Deaktivieren faengt
|
||||||
|
rueckwaerts. Klick auf Set darf Gewicht/Reps nie veraendern.
|
||||||
|
- **Reset-Satz**: Knopf setzt das Set auf das Gewicht des letzten abgeschlossenen Tages
|
||||||
|
(set-spezifisch, heutige in-Arbeit-Session zaehlt nicht).
|
||||||
|
- **Fragezeichen-Icon** neben jedem Uebungsnamen: oeffnet Overlay mit Text-Erklaerung
|
||||||
|
der Uebung + der passenden Maschine. (Text reicht, kein Bild.)
|
||||||
|
- Optionale Notiz pro Session.
|
||||||
|
|
||||||
|
### Session-Lebenszyklus
|
||||||
|
- "Session starten" / "Session speichern". Mehrere Sessions am Tag erlaubt.
|
||||||
|
- Start- und Endzeit. Nach "abschliessen" erscheint die Session SOFORT im Verlauf
|
||||||
|
(ohne Seiten-Reload).
|
||||||
|
- Solange keine Session gestartet, passiert nichts im Verlauf (kein Geist).
|
||||||
|
|
||||||
|
### Verlauf
|
||||||
|
- Abgeschlossene Sessions mit Datum + Zeit von-bis.
|
||||||
|
- **Inline bearbeiten unter dem Verlauf**: Datum, Start/Ende aenderbar; Workout-Eintraege
|
||||||
|
(Gewicht/Reps bzw. Cardio-Felder) direkt aenderbar; Session loeschbar.
|
||||||
|
- Bearbeiten-Tab bleibt im Verlauf, blutet nicht in die Heutige-Ansicht.
|
||||||
|
|
||||||
|
### Statistik
|
||||||
|
- Pro Uebung die Steigerung ueber die Zeit (bestes Gewicht je Termin,
|
||||||
|
Differenz zur ersten Messung).
|
||||||
|
|
||||||
|
### Export / Import
|
||||||
|
- Kompletter Stand als JSON und als CSV raus und rein (fuer Back-ups und
|
||||||
|
Tabellen-Auswertung). Format klar allabelt.
|
||||||
|
|
||||||
|
### Cardio (Laufen / Radfahren)
|
||||||
|
- Feld-Templates aus der Config (Distanz, Dauer, Pace, Puls, Hoehenmeter).
|
||||||
|
- Einheiten aus der Config (km, min, min/km, bpm, m).
|
||||||
|
|
||||||
|
## Maschinen / Trainingspraeferenz
|
||||||
|
|
||||||
|
- Trainiert mit Freihanteln UND Maschinen, tendenziell eher Maschine (Technogym).
|
||||||
|
- **Bizeps bevorzugt am Kabelturm.**
|
||||||
|
- Der Plan soll die fehlenden Standard-Maschinen abdecken (Technogym Selection-Linie
|
||||||
|
als Referenz: Chest Press, Pectoral, Shoulder Press, Lat Machine, Low Row, Reverse
|
||||||
|
Fly, Leg Press, Leg Curl, Leg Extension, Calf, Arm Curl/Preacher, Glute/Hip Thrust
|
||||||
|
u.a.) inkl. der Preacher-(Arm Cavlier)-Maschine.
|
||||||
|
- Das sind Richtwerte, kein Muss: Welche Maschinen im Studio wirklich stehen,
|
||||||
|
kann beim Training angepasst werden - Config, nicht Code.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
We-geilektung, KEIN Detail-Diktat: Nutzbarkeit hat Vorrang. Eine Regel, die die
|
||||||
|
Software unbrauchbar macht, wird gebrochen und begruendet.
|
||||||
|
|
||||||
|
- Harte Kanten: 0px Radius, klare 2px-Border, dunkler Grund.
|
||||||
|
- Flache Farben, ein Akzent, keine Verlaeufe, kein Glow, kein Glassmorphism.
|
||||||
|
- Kein Inter/Roboto-Systemstack als einzige Schrift unreflektiert.
|
||||||
|
- Bewusste Typo/Hierarchie ueber Abstand und Gewicht statt "alles gleich".
|
||||||
|
- Zustaende (Hover/Mouse/Focus/Active) mitdenken.
|
||||||
|
- Keine Emojis als Icons, keine rohen Unicode-Pfeile in Labels, keine Em-Dashes als
|
||||||
|
Stilmittel.
|
||||||
|
- Kompakt, kein "zentrierter Hero + 3 Karten"-SaaS-Skelett.
|
||||||
|
- **WICHTIG**: nicht ueberstilisiert designen. Es fehlt Feinarbeit - simpel, klar,
|
||||||
|
benutzbar. Design gibt die Richtung, macht die App nicht zerbrechlich.
|
||||||
|
|
||||||
|
## Technischer Rahmen (Empfehlung, nicht zwingend)
|
||||||
|
|
||||||
|
- Frontend: eine HTML-Datei Struktur, eine CSS-Datei, eine JS-Datei (kein Framework).
|
||||||
|
- Backend: `python3` mit Standardbibliothek (`http.server`) als JSON-API + statische
|
||||||
|
Dateien. SQLite fuer die Daten. Kein Inline-Script/Style in HTML - Klassen + CSS.
|
||||||
|
- Dockerfile + docker-compose (CasaOS-tauglich: Daten-Verzeichnis als echter Ordner).
|
||||||
|
|
||||||
|
## Abgeben
|
||||||
|
|
||||||
|
Code + kurze README (was es kann, wie starten, wie den Plan in der Config aendern).
|
||||||
|
Getestet und lauffaehig, nicht nur beschrieben.
|
||||||
Reference in new issue
Block a user