working PWA app
arnaucube git@arnaucube.com
Mon, 06 Jul 2026 12:41:26 +0200
10 files changed,
4037 insertions(+),
0 deletions(-)
A
LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>.
A
app.js
@@ -0,0 +1,1206 @@
+const STORAGE_KEY = "kcal-intakes-v1"; +const THEME_KEY = "kcal-theme-v1"; +const GOALS_KEY = "kcal-goals-v1"; +const FOODS_URL = "foods.json"; +const DEFAULT_GOALS = { kcal: 2200, protein: 140, carbs: 220, fat: 70, sugar: 60 }; + +const state = { + foods: [], + intakes: [], + selectedDate: "", + theme: "light", + goals: { ...DEFAULT_GOALS }, + foodSuggestionIndex: -1, +}; + +const elements = { + foodInput: document.getElementById("food-input"), + gramsInput: document.getElementById("grams-input"), + foodSuggestions: document.getElementById("food-suggestions"), + foodHint: document.getElementById("food-hint"), + previewBase: document.getElementById("preview-base"), + previewScaled: document.getElementById("preview-scaled"), + addForm: document.getElementById("add-intake"), + intakeForm: document.getElementById("intake-form"), + entryDate: document.getElementById("entry-date"), + todayDate: document.getElementById("today-date"), + todaySelectDate: document.getElementById("today-select-date"), + todayTotals: document.getElementById("today-totals"), + todayList: document.getElementById("today-list"), + historyTotals: document.getElementById("history-totals"), + historyBreakdown: document.getElementById("history-breakdown"), + historyKcalChart: document.getElementById("history-kcal-chart"), + historyMacroChart: document.getElementById("history-macro-chart"), + kcalChartCard: document.getElementById("kcal-chart-card"), + macroChartCard: document.getElementById("macro-chart-card"), + settingsView: document.getElementById("settings-view"), + goalsForm: document.getElementById("goals-form"), + goalKcal: document.getElementById("goal-kcal"), + goalProtein: document.getElementById("goal-protein"), + goalCarbs: document.getElementById("goal-carbs"), + goalFat: document.getElementById("goal-fat"), + goalSugar: document.getElementById("goal-sugar"), + goalsHint: document.getElementById("goals-hint"), + exportDataButton: document.getElementById("export-data"), + importDataButton: document.getElementById("import-data"), + importFileInput: document.getElementById("import-file"), + rangeSelect: document.getElementById("range-select"), + anchorDate: document.getElementById("anchor-date"), + themeToggle: document.getElementById("theme-toggle"), + tabs: document.querySelectorAll(".tab"), + todayView: document.getElementById("today-view"), + historyView: document.getElementById("history-view"), + rowTemplate: document.getElementById("intake-row"), +}; + +const toISODate = (date) => date.toISOString().slice(0, 10); + +const fmtDate = (date) => + date.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); + +const fmtRange = (start, end) => { + const options = { month: "short", day: "numeric" }; + const startLabel = start.toLocaleDateString(undefined, options); + const endLabel = end.toLocaleDateString(undefined, options); + if (startLabel === endLabel) return startLabel; + return `${startLabel} - ${endLabel}`; +}; + +const fromISODate = (dateKey) => new Date(`${dateKey}T00:00:00`); + +const fmtChartDate = (dateKey) => + fromISODate(dateKey).toLocaleDateString(undefined, { + month: "numeric", + day: "numeric", + }); +const SVG_NS = "http://www.w3.org/2000/svg"; + +const loadIntakes = () => { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + state.intakes = Array.isArray(parsed) + ? parsed + .filter((entry) => entry && typeof entry === "object") + .map((entry) => ({ + ...entry, + sugar: typeof entry.sugar === "number" ? entry.sugar : 0, + })) + : []; +}; + +const saveIntakes = () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.intakes)); +}; + +const loadTheme = () => { + const savedTheme = localStorage.getItem(THEME_KEY); + if (savedTheme === "light" || savedTheme === "dark") { + state.theme = savedTheme; + return; + } + state.theme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +}; + +const saveTheme = () => { + localStorage.setItem(THEME_KEY, state.theme); +}; + +const normalizeGoals = (rawGoals) => { + const base = { ...DEFAULT_GOALS }; + if (!rawGoals || typeof rawGoals !== "object") return base; + + const goalKeys = ["kcal", "protein", "carbs", "fat", "sugar"]; + goalKeys.forEach((key) => { + const value = Number(rawGoals[key]); + if (Number.isFinite(value) && value >= 0) base[key] = value; + }); + return base; +}; + +const loadGoals = () => { + const rawGoals = localStorage.getItem(GOALS_KEY); + state.goals = rawGoals ? normalizeGoals(JSON.parse(rawGoals)) : { ...DEFAULT_GOALS }; +}; + +const saveGoals = () => { + localStorage.setItem(GOALS_KEY, JSON.stringify(state.goals)); +}; + +const getLatestIntakeDate = () => { + if (!state.intakes.length) return toISODate(new Date()); + return state.intakes.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + state.intakes[0].date + ); +}; + +const createBackupPayload = () => ({ + app: "macros", + version: 1, + exportedAt: new Date().toISOString(), + storage: { + [STORAGE_KEY]: state.intakes, + [THEME_KEY]: state.theme, + [GOALS_KEY]: state.goals, + }, +}); + +const parseImportedPayload = (payload) => { + if (!payload || typeof payload !== "object") return null; + + const source = + payload.storage && typeof payload.storage === "object" + ? payload.storage + : payload; + + let importedIntakes = source[STORAGE_KEY] ?? source.intakes; + let importedTheme = source[THEME_KEY] ?? source.theme; + let importedGoals = source[GOALS_KEY] ?? source.goals; + + if (typeof importedIntakes === "string") { + importedIntakes = JSON.parse(importedIntakes); + } + if (typeof importedTheme === "string") { + importedTheme = importedTheme.trim().toLowerCase(); + } + if (typeof importedGoals === "string") { + importedGoals = JSON.parse(importedGoals); + } + + if (!Array.isArray(importedIntakes)) return null; + if (importedTheme !== "light" && importedTheme !== "dark") importedTheme = "light"; + + const intakes = importedIntakes + .filter( + (entry) => + entry && + typeof entry === "object" && + typeof entry.food === "string" && + typeof entry.date === "string" && + typeof entry.grams === "number" && + typeof entry.kcal === "number" && + typeof entry.protein === "number" && + typeof entry.carbs === "number" && + typeof entry.fat === "number" + ) + .map((entry) => ({ + id: typeof entry.id === "string" ? entry.id : createEntryId(), + food: entry.food, + grams: entry.grams, + date: entry.date, + kcal: entry.kcal, + protein: entry.protein, + carbs: entry.carbs, + fat: entry.fat, + sugar: typeof entry.sugar === "number" ? entry.sugar : 0, + })); + + return { intakes, theme: importedTheme, goals: normalizeGoals(importedGoals) }; +}; + +const findFood = (name) => + state.foods.find((food) => food.name.toLowerCase() === name.toLowerCase()); + +const fmtMacroLine = (macros) => + `kcal ${macros.kcal.toFixed(0)} · P ${macros.protein.toFixed( + 1 + )}g · C ${macros.carbs.toFixed(1)}g · S ${macros.sugar.toFixed( + 1 + )}g · F ${macros.fat.toFixed(1)}g`; + +const renderIntakePreview = () => { + const name = elements.foodInput.value.trim(); + const grams = Number(elements.gramsInput.value); + + if (!name) { + elements.previewBase.textContent = "Select a food to preview macros."; + elements.previewScaled.textContent = "Enter grams to see calculated macros."; + return; + } + + const food = findFood(name); + if (!food) { + elements.previewBase.textContent = "Food not found in list."; + elements.previewScaled.textContent = "Choose a listed food to calculate values."; + return; + } + + elements.previewBase.textContent = fmtMacroLine(food); + + if (!Number.isFinite(grams) || grams <= 0) { + elements.previewScaled.textContent = "Enter a valid grams value."; + return; + } + + elements.previewScaled.textContent = fmtMacroLine(calcFromFood(food, grams)); +}; + +const getFilteredFoods = (query) => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return state.foods.slice(0, 12); + + const startsWith = state.foods.filter((food) => + food.name.toLowerCase().startsWith(normalized) + ); + const includes = state.foods.filter( + (food) => + !food.name.toLowerCase().startsWith(normalized) && + food.name.toLowerCase().includes(normalized) + ); + return [...startsWith, ...includes].slice(0, 12); +}; + +const hideFoodSuggestions = () => { + elements.foodSuggestions.classList.add("hidden"); + elements.foodSuggestions.innerHTML = ""; + state.foodSuggestionIndex = -1; +}; + +const selectFoodSuggestion = (foodName) => { + elements.foodInput.value = foodName; + hideFoodSuggestions(); + renderIntakePreview(); +}; + +const renderFoodSuggestions = (query) => { + const foods = getFilteredFoods(query); + state.foodSuggestionIndex = -1; + elements.foodSuggestions.innerHTML = ""; + + if (!foods.length) { + hideFoodSuggestions(); + return; + } + + foods.forEach((food, index) => { + const option = document.createElement("button"); + option.type = "button"; + option.className = "food-suggestion"; + option.textContent = food.name; + option.dataset.index = String(index); + option.addEventListener("mousedown", (event) => { + event.preventDefault(); + selectFoodSuggestion(food.name); + }); + elements.foodSuggestions.appendChild(option); + }); + + elements.foodSuggestions.classList.remove("hidden"); +}; + +const moveFoodSuggestionSelection = (direction) => { + const options = Array.from( + elements.foodSuggestions.querySelectorAll(".food-suggestion") + ); + if (!options.length) return; + + if (elements.foodSuggestions.classList.contains("hidden")) { + renderFoodSuggestions(elements.foodInput.value); + return; + } + + state.foodSuggestionIndex += direction; + if (state.foodSuggestionIndex < 0) state.foodSuggestionIndex = options.length - 1; + if (state.foodSuggestionIndex >= options.length) state.foodSuggestionIndex = 0; + + options.forEach((option, idx) => + option.classList.toggle("active", idx === state.foodSuggestionIndex) + ); +}; + +const pickActiveFoodSuggestion = () => { + if (state.foodSuggestionIndex < 0) return false; + const active = elements.foodSuggestions.querySelector(".food-suggestion.active"); + if (!active) return false; + selectFoodSuggestion(active.textContent); + return true; +}; + +const initFoodAutocomplete = () => { + elements.foodInput.addEventListener("focus", () => { + renderFoodSuggestions(elements.foodInput.value); + }); + + elements.foodInput.addEventListener("input", () => { + renderFoodSuggestions(elements.foodInput.value); + }); + + elements.foodInput.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveFoodSuggestionSelection(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveFoodSuggestionSelection(-1); + return; + } + + if (event.key === "Enter" && pickActiveFoodSuggestion()) { + event.preventDefault(); + return; + } + + if (event.key === "Escape") { + hideFoodSuggestions(); + } + }); + + elements.foodInput.addEventListener("blur", () => { + setTimeout(hideFoodSuggestions, 100); + }); +}; + +const calcFromFood = (food, grams) => { + const ratio = grams / 100; + return { + kcal: food.kcal * ratio, + protein: food.protein * ratio, + carbs: food.carbs * ratio, + fat: food.fat * ratio, + sugar: (food.sugar || 0) * ratio, + }; +}; + +const createEntryId = () => { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const aggregateTotals = (items) => + items.reduce( + (acc, item) => { + acc.kcal += item.kcal; + acc.protein += item.protein; + acc.carbs += item.carbs; + acc.fat += item.fat; + acc.sugar += item.sugar || 0; + return acc; + }, + { kcal: 0, protein: 0, carbs: 0, fat: 0, sugar: 0 } + ); + +const median = (values) => { + if (!values.length) return 0; + const sorted = values.slice().sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2; + } + return sorted[mid]; +}; + +const getDateKeysInRange = (start, end) => { + const keys = []; + const cursor = new Date(start); + while (cursor <= end) { + keys.push(toISODate(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + return keys; +}; + +const ensureChartTooltip = (chartCard) => { + let tooltip = chartCard.querySelector(".chart-tooltip"); + if (!tooltip) { + tooltip = document.createElement("div"); + tooltip.className = "chart-tooltip hidden"; + chartCard.appendChild(tooltip); + } + return tooltip; +}; + +const showChartTooltip = (chartCard, text, event) => { + const tooltip = ensureChartTooltip(chartCard); + tooltip.textContent = text; + tooltip.classList.remove("hidden"); + + const rect = chartCard.getBoundingClientRect(); + const left = Math.max( + 8, + Math.min(event.clientX - rect.left + 10, rect.width - 180) + ); + const top = Math.max( + 8, + Math.min(event.clientY - rect.top - 34, rect.height - 38) + ); + + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; +}; + +const hideChartTooltip = (chartCard) => { + const tooltip = chartCard.querySelector(".chart-tooltip"); + if (tooltip) tooltip.classList.add("hidden"); +}; + +const getGoalStatus = (value, goal, metric) => { + const tolerance = metric === "kcal" ? 1 : 0.1; + if (value > goal + tolerance) return "over"; + if (value < goal - tolerance) return "under"; + return "on"; +}; + +const goalDeltaLabel = (value, goal, unit, metric) => { + const delta = value - goal; + const status = getGoalStatus(value, goal, metric); + if (status === "on") return `On goal (${goal.toFixed(0)}${unit})`; + const direction = status === "over" ? "Over" : "Under"; + return `${direction} by ${Math.abs(delta).toFixed(1)}${unit}`; +}; + +const renderGoalsForm = () => { + elements.goalKcal.value = state.goals.kcal.toFixed(0); + elements.goalProtein.value = state.goals.protein.toFixed(0); + elements.goalCarbs.value = state.goals.carbs.toFixed(0); + elements.goalFat.value = state.goals.fat.toFixed(0); + elements.goalSugar.value = state.goals.sugar.toFixed(0); +}; + +const renderCaloriesChart = (series) => { + elements.historyKcalChart.innerHTML = ""; + hideChartTooltip(elements.kcalChartCard); + + const hasData = series.some((day) => day.kcal > 0); + + if (!hasData) { + elements.historyKcalChart.innerHTML = + '<p class="hint">No data for this chart range.</p>'; + return; + } + + const maxKcal = Math.max(...series.map((day) => day.kcal), 1); + const kcalGoal = Math.max(state.goals.kcal, 0); + const maxScale = Math.max(maxKcal, kcalGoal, 1); + const width = Math.max(320, series.length * 44); + const height = 180; + const pad = { top: 12, right: 12, bottom: 28, left: 20 }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + const step = series.length > 1 ? plotW / (series.length - 1) : 0; + const points = series.map((day, index) => ({ + day, + x: pad.left + step * index, + y: pad.top + plotH - (day.kcal / maxScale) * plotH, + })); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute("class", "line-chart-svg"); + + const axis = document.createElementNS(SVG_NS, "line"); + axis.setAttribute("x1", String(pad.left)); + axis.setAttribute("x2", String(width - pad.right)); + axis.setAttribute("y1", String(height - pad.bottom)); + axis.setAttribute("y2", String(height - pad.bottom)); + axis.setAttribute("class", "line-axis"); + svg.appendChild(axis); + + const linePath = points + .map((point, index) => + `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}` + ) + .join(" "); + + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", linePath); + path.setAttribute("class", "line-path"); + svg.appendChild(path); + + if (kcalGoal > 0) { + const goalY = pad.top + plotH - (kcalGoal / maxScale) * plotH; + const goalLine = document.createElementNS(SVG_NS, "line"); + goalLine.setAttribute("x1", String(pad.left)); + goalLine.setAttribute("x2", String(width - pad.right)); + goalLine.setAttribute("y1", String(goalY)); + goalLine.setAttribute("y2", String(goalY)); + goalLine.setAttribute("class", "goal-line"); + svg.appendChild(goalLine); + } + + points.forEach((point) => { + const circle = document.createElementNS(SVG_NS, "circle"); + circle.setAttribute("cx", point.x.toFixed(2)); + circle.setAttribute("cy", point.y.toFixed(2)); + circle.setAttribute("r", "3.5"); + const goalStatus = getGoalStatus(point.day.kcal, state.goals.kcal, "kcal"); + const trendClass = `goal-${goalStatus}`; + circle.setAttribute("class", `line-point ${trendClass}`); + const pointLabel = `${fmtChartDate(point.day.date)}: ${point.day.kcal.toFixed( + 0 + )} kcal · ${goalDeltaLabel(point.day.kcal, state.goals.kcal, " kcal", "kcal")}`; + circle.addEventListener("mouseenter", (event) => + showChartTooltip(elements.kcalChartCard, pointLabel, event) + ); + circle.addEventListener("mousemove", (event) => + showChartTooltip(elements.kcalChartCard, pointLabel, event) + ); + circle.addEventListener("mouseleave", () => + hideChartTooltip(elements.kcalChartCard) + ); + svg.appendChild(circle); + }); + + const scroll = document.createElement("div"); + scroll.className = "line-chart-scroll"; + scroll.appendChild(svg); + elements.historyKcalChart.appendChild(scroll); + + const labels = document.createElement("div"); + labels.className = "line-chart-labels"; + labels.innerHTML = `<span>${fmtChartDate(series[0].date)}</span><span>${fmtChartDate( + series[series.length - 1].date + )}</span>`; + elements.historyKcalChart.appendChild(labels); +}; + +const renderMacroChart = (series) => { + elements.historyMacroChart.innerHTML = ""; + hideChartTooltip(elements.macroChartCard); + + const macroKcals = series.map((day) => ({ + ...day, + proteinKcal: day.protein * 4, + carbsKcal: day.carbs * 4, + fatKcal: day.fat * 9, + })); + const maxTotal = Math.max( + ...macroKcals.map((day) => day.proteinKcal + day.carbsKcal + day.fatKcal), + 1 + ); + const hasData = macroKcals.some( + (day) => day.proteinKcal + day.carbsKcal + day.fatKcal > 0 + ); + + if (!hasData) { + elements.historyMacroChart.innerHTML = + '<p class="hint">No data for this chart range.</p>'; + return; + } + + macroKcals.forEach((day) => { + const total = day.proteinKcal + day.carbsKcal + day.fatKcal; + const col = document.createElement("div"); + col.className = "chart-col"; + + const value = document.createElement("div"); + value.className = "chart-value"; + value.textContent = `${total.toFixed(0)}`; + + const track = document.createElement("div"); + track.className = "chart-track stacked"; + track.style.height = `${Math.max((total / maxTotal) * 100, 2)}%`; + const dayLabel = `${fmtChartDate(day.date)}: P ${day.protein.toFixed( + 1 + )}g (${goalDeltaLabel(day.protein, state.goals.protein, "g", "protein")}), C ${day.carbs.toFixed( + 1 + )}g (${goalDeltaLabel(day.carbs, state.goals.carbs, "g", "carbs")}), S ${day.sugar.toFixed( + 1 + )}g (${goalDeltaLabel(day.sugar, state.goals.sugar, "g", "sugar")}), F ${day.fat.toFixed( + 1 + )}g (${goalDeltaLabel(day.fat, state.goals.fat, "g", "fat")})`; + track.addEventListener("mouseenter", (event) => + showChartTooltip(elements.macroChartCard, dayLabel, event) + ); + track.addEventListener("mousemove", (event) => + showChartTooltip(elements.macroChartCard, dayLabel, event) + ); + track.addEventListener("mouseleave", () => + hideChartTooltip(elements.macroChartCard) + ); + + const proteinSeg = document.createElement("div"); + proteinSeg.className = "chart-seg protein"; + proteinSeg.style.height = `${(day.proteinKcal / total) * 100 || 0}%`; + + const carbsSeg = document.createElement("div"); + carbsSeg.className = "chart-seg carbs"; + carbsSeg.style.height = `${(day.carbsKcal / total) * 100 || 0}%`; + + const fatSeg = document.createElement("div"); + fatSeg.className = "chart-seg fat"; + fatSeg.style.height = `${(day.fatKcal / total) * 100 || 0}%`; + + const label = document.createElement("div"); + label.className = "chart-label"; + label.textContent = fmtChartDate(day.date); + + track.appendChild(fatSeg); + track.appendChild(carbsSeg); + track.appendChild(proteinSeg); + col.appendChild(value); + col.appendChild(track); + col.appendChild(label); + elements.historyMacroChart.appendChild(col); + }); +}; + + +const renderTotals = (target, totals, goals = null) => { + target.innerHTML = ""; + const valueWithGoal = (current, goal, digits = 0, unit = "") => { + if (!goals) { + return `${current.toFixed( + digits + )}<span class="total-unit">${unit}</span>`.trim(); + } + return `${current.toFixed(digits)}<span class="goal-soft">/${goal.toFixed( + digits + )}</span><span class="total-unit">${unit}</span>`.trim(); + }; + + const cards = [ + { + label: "Calories", + value: valueWithGoal(totals.kcal, goals?.kcal ?? 0, 0, "kcal"), + }, + { + label: "Protein", + value: valueWithGoal(totals.protein, goals?.protein ?? 0, 1, "g"), + }, + { label: "Carbs", value: valueWithGoal(totals.carbs, goals?.carbs ?? 0, 1, "g") }, + { label: "Sugar", value: valueWithGoal(totals.sugar, goals?.sugar ?? 0, 1, "g") }, + { label: "Fat", value: valueWithGoal(totals.fat, goals?.fat ?? 0, 1, "g") }, + ]; + cards.forEach((card) => { + const div = document.createElement("div"); + div.className = "total-card"; + div.innerHTML = `<strong>${card.value}</strong><span>${card.label}</span>`; + target.appendChild(div); + }); +}; + +const renderList = (target, items, titleFn, onDelete, onEdit) => { + target.innerHTML = ""; + if (!items.length) { + target.innerHTML = `<p class="hint">No entries yet.</p>`; + return; + } + + items.forEach((item) => { + const node = elements.rowTemplate.content.cloneNode(true); + node.querySelector(".row-title").textContent = titleFn(item); + node.querySelector(".row-sub").textContent = item.sub; + node.querySelector(".row-kcal").textContent = `${item.kcal.toFixed(0)} kcal`; + const editButton = node.querySelector(".row-edit"); + if (typeof onEdit === "function") { + editButton.classList.remove("hidden"); + editButton.addEventListener("click", () => onEdit(item)); + } + const deleteButton = node.querySelector(".row-delete"); + if (typeof onDelete === "function") { + deleteButton.classList.remove("hidden"); + deleteButton.addEventListener("click", () => onDelete(item)); + } + target.appendChild(node); + }); +}; + +const renderHistoryListByDay = (target, entries) => { + target.innerHTML = ""; + if (!entries.length) { + target.innerHTML = '<p class="hint">No entries for this range.</p>'; + return; + } + + const groups = entries.reduce((acc, entry) => { + if (!acc[entry.date]) acc[entry.date] = []; + acc[entry.date].push(entry); + return acc; + }, {}); + + const orderedDates = Object.keys(groups).sort((a, b) => b.localeCompare(a)); + + orderedDates.forEach((dateKey) => { + const daySection = document.createElement("section"); + daySection.className = "history-day-group"; + + const dayTitle = document.createElement("h3"); + dayTitle.className = "history-day-title"; + dayTitle.textContent = fmtDate(fromISODate(dateKey)); + daySection.appendChild(dayTitle); + + groups[dateKey].forEach((entry) => { + const node = elements.rowTemplate.content.cloneNode(true); + node.querySelector(".row-title").textContent = entry.food; + node.querySelector(".row-sub").textContent = `${entry.grams} g · P ${entry.protein.toFixed( + 1 + )} / C ${entry.carbs.toFixed(1)} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed( + 1 + )}`; + node.querySelector(".row-kcal").textContent = `${entry.kcal.toFixed(0)} kcal`; + daySection.appendChild(node); + }); + + target.appendChild(daySection); + }); +}; + +const getWeekStart = (date) => { + const start = new Date(date); + const day = start.getDay() || 7; + start.setDate(start.getDate() - (day - 1)); + return start; +}; + +const renderHistoryListByWeek = (target, entries) => { + target.innerHTML = ""; + if (!entries.length) { + target.innerHTML = '<p class="hint">No entries for this range.</p>'; + return; + } + + const groups = entries.reduce((acc, entry) => { + const weekStartKey = toISODate(getWeekStart(fromISODate(entry.date))); + if (!acc[weekStartKey]) acc[weekStartKey] = []; + acc[weekStartKey].push(entry); + return acc; + }, {}); + + const orderedWeekKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a)); + + orderedWeekKeys.forEach((weekStartKey) => { + const weekSection = document.createElement("section"); + weekSection.className = "history-day-group"; + + const weekStart = fromISODate(weekStartKey); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + + const weekTitle = document.createElement("h3"); + weekTitle.className = "history-day-title"; + weekTitle.textContent = `Week of ${fmtRange(weekStart, weekEnd)}`; + weekSection.appendChild(weekTitle); + + groups[weekStartKey].forEach((entry) => { + const entryDate = fromISODate(entry.date); + const node = elements.rowTemplate.content.cloneNode(true); + node.querySelector(".row-title").textContent = `${entry.food} · ${fmtDate(entryDate)}`; + node.querySelector(".row-sub").textContent = `${entry.grams} g · P ${entry.protein.toFixed( + 1 + )} / C ${entry.carbs.toFixed(1)} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed( + 1 + )}`; + node.querySelector(".row-kcal").textContent = `${entry.kcal.toFixed(0)} kcal`; + weekSection.appendChild(node); + }); + + target.appendChild(weekSection); + }); +}; + +const renderDay = () => { + const selected = new Date(state.selectedDate); + const selectedKey = state.selectedDate; + const todayKey = toISODate(new Date()); + const label = + selectedKey === todayKey ? `${fmtDate(selected)} (Today)` : fmtDate(selected); + elements.todayDate.textContent = label; + elements.todaySelectDate.value = selectedKey; + elements.entryDate.value = selectedKey; + + const dayEntries = state.intakes.filter((entry) => entry.date === selectedKey); + const totals = aggregateTotals(dayEntries); + renderTotals(elements.todayTotals, totals, state.goals); + + const rows = dayEntries.map((entry) => ({ + ...entry, + sub: `${entry.grams} g · P ${entry.protein.toFixed(1)} / C ${entry.carbs.toFixed( + 1 + )} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(1)}`, + })); + + renderList( + elements.todayList, + rows, + (entry) => entry.food, + (entry) => deleteIntake(entry.id), + (entry) => editIntakeGrams(entry) + ); +}; + +const getRange = (anchor, type) => { + const start = new Date(anchor); + const end = new Date(anchor); + + if (type === "week") { + const day = start.getDay() || 7; + start.setDate(start.getDate() - (day - 1)); + end.setDate(start.getDate() + 6); + } + + if (type === "month") { + start.setDate(1); + end.setMonth(start.getMonth() + 1); + end.setDate(0); + } + + if (type === "quarter") { + const quarter = Math.floor(start.getMonth() / 3); + start.setMonth(quarter * 3, 1); + end.setMonth(start.getMonth() + 3); + end.setDate(0); + } + + return { start, end }; +}; + +const renderHistory = () => { + const type = elements.rangeSelect.value; + const anchor = elements.anchorDate.value + ? new Date(elements.anchorDate.value) + : new Date(); + + const { start, end } = getRange(anchor, type); + const startKey = toISODate(start); + const endKey = toISODate(end); + + const items = state.intakes.filter( + (entry) => entry.date >= startKey && entry.date <= endKey + ); + + const grouped = items.reduce((acc, entry) => { + if (!acc[entry.date]) acc[entry.date] = []; + acc[entry.date].push(entry); + return acc; + }, {}); + + const series = getDateKeysInRange(start, end).map((dateKey) => ({ + date: dateKey, + ...aggregateTotals(grouped[dateKey] || []), + })); + + const positiveValues = (key) => + series.map((day) => day[key]).filter((value) => value > 0); + + const medianTotals = { + kcal: median(positiveValues("kcal")), + protein: median(positiveValues("protein")), + carbs: median(positiveValues("carbs")), + fat: median(positiveValues("fat")), + sugar: median(positiveValues("sugar")), + }; + renderTotals(elements.historyTotals, medianTotals, state.goals); + + renderCaloriesChart(series); + renderMacroChart(series); + + if (type === "quarter") { + elements.historyBreakdown.classList.add("hidden"); + elements.historyBreakdown.innerHTML = ""; + elements.historyView.querySelector( + ".card-header p" + ).textContent = `${type[0].toUpperCase() + type.slice(1)} view · ${fmtRange( + start, + end + )}`; + return; + } + elements.historyBreakdown.classList.remove("hidden"); + + const sortedItems = items + .slice() + .sort((a, b) => b.date.localeCompare(a.date)); + + if (!sortedItems.length) { + elements.historyBreakdown.innerHTML = '<p class="hint">No entries for this range.</p>'; + } else if (type === "week") { + renderHistoryListByDay(elements.historyBreakdown, sortedItems); + } else if (type === "month") { + renderHistoryListByWeek(elements.historyBreakdown, sortedItems); + } else { + const rows = sortedItems.map((entry) => { + const entryDate = fromISODate(entry.date); + return { + food: `${entry.food} · ${fmtDate(entryDate)}`, + kcal: entry.kcal, + sub: `${entry.grams} g · P ${entry.protein.toFixed( + 1 + )} / C ${entry.carbs.toFixed( + 1 + )} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(1)}`, + }; + }); + renderList(elements.historyBreakdown, rows, (entry) => entry.food); + } + + elements.historyView.querySelector( + ".card-header p" + ).textContent = `${type[0].toUpperCase() + type.slice(1)} view · ${fmtRange( + start, + end + )}`; +}; + +const addIntake = (event) => { + event.preventDefault(); + const name = elements.foodInput.value.trim(); + const grams = Number(elements.gramsInput.value); + const entryDate = elements.entryDate.value || state.selectedDate; + + if (!name || !grams || !entryDate) return; + + const food = findFood(name); + if (!food) { + elements.foodHint.textContent = "Food not found. Please pick from the list."; + return; + } + + const macros = calcFromFood(food, grams); + const entry = { + id: createEntryId(), + food: food.name, + grams, + date: entryDate, + ...macros, + }; + + state.intakes.unshift(entry); + saveIntakes(); + state.selectedDate = entryDate; + elements.addForm.reset(); + elements.gramsInput.value = 100; + hideFoodSuggestions(); + elements.entryDate.value = state.selectedDate; + elements.foodHint.textContent = "Logged!"; + renderIntakePreview(); + renderDay(); + renderHistory(); +}; + +const deleteIntake = (entryId) => { + const intake = state.intakes.find((entry) => entry.id === entryId); + const itemLabel = intake ? `${intake.food} (${intake.grams}g)` : "this intake"; + const shouldDelete = confirm(`Delete ${itemLabel}?`); + if (!shouldDelete) return; + + state.intakes = state.intakes.filter((entry) => entry.id !== entryId); + saveIntakes(); + elements.foodHint.textContent = "Entry removed."; + renderDay(); + renderHistory(); +}; + +const editIntakeGrams = (entry) => { + const next = prompt(`Update grams for ${entry.food}:`, String(entry.grams)); + if (next === null) return; + + const grams = Number(next); + if (!Number.isFinite(grams) || grams <= 0) { + elements.foodHint.textContent = "Please enter a valid grams value."; + return; + } + + const food = findFood(entry.food); + if (!food) { + elements.foodHint.textContent = "Food not found, cannot recalculate macros."; + return; + } + + const updatedMacros = calcFromFood(food, grams); + const intake = state.intakes.find((item) => item.id === entry.id); + if (!intake) return; + + intake.grams = grams; + intake.kcal = updatedMacros.kcal; + intake.protein = updatedMacros.protein; + intake.carbs = updatedMacros.carbs; + intake.fat = updatedMacros.fat; + intake.sugar = updatedMacros.sugar; + + saveIntakes(); + elements.foodHint.textContent = "Entry updated."; + renderDay(); + renderHistory(); +}; + +const applyTheme = () => { + document.documentElement.setAttribute("data-theme", state.theme); + const nextMode = state.theme === "dark" ? "light" : "dark"; + const label = `Switch to ${nextMode} mode`; + elements.themeToggle.setAttribute("aria-label", label); + elements.themeToggle.setAttribute("title", label); +}; + +const toggleTheme = () => { + state.theme = state.theme === "dark" ? "light" : "dark"; + applyTheme(); + saveTheme(); +}; + +const exportLocalData = () => { + const payload = createBackupPayload(); + const blob = new Blob([JSON.stringify(payload, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const dateStamp = toISODate(new Date()); + link.href = url; + link.download = `macros-backup-${dateStamp}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + elements.goalsHint.textContent = "Backup exported."; +}; + +const importLocalData = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const raw = await file.text(); + const parsed = parseImportedPayload(JSON.parse(raw)); + if (!parsed) throw new Error("Invalid backup format."); + + state.intakes = parsed.intakes; + state.theme = parsed.theme; + state.goals = parsed.goals; + state.selectedDate = getLatestIntakeDate(); + + saveIntakes(); + saveTheme(); + saveGoals(); + applyTheme(); + renderGoalsForm(); + + elements.anchorDate.value = toISODate(new Date()); + elements.todaySelectDate.value = state.selectedDate; + elements.entryDate.value = state.selectedDate; + elements.goalsHint.textContent = `Imported ${state.intakes.length} entries.`; + + renderDay(); + renderHistory(); + } catch (_error) { + elements.goalsHint.textContent = "Import failed. Use a valid backup file."; + } finally { + event.target.value = ""; + } +}; + +const saveGoalsFromSettings = (event) => { + event.preventDefault(); + state.goals = normalizeGoals({ + kcal: Number(elements.goalKcal.value), + protein: Number(elements.goalProtein.value), + carbs: Number(elements.goalCarbs.value), + fat: Number(elements.goalFat.value), + sugar: Number(elements.goalSugar.value), + }); + saveGoals(); + renderGoalsForm(); + elements.goalsHint.textContent = "Goals saved."; + renderHistory(); + renderDay(); +}; + +const initTabs = () => { + elements.tabs.forEach((tab) => { + tab.addEventListener("click", () => { + elements.tabs.forEach((btn) => btn.classList.remove("active")); + tab.classList.add("active"); + + const view = tab.dataset.view; + if (view === "today") { + elements.intakeForm.classList.remove("hidden"); + elements.todayView.classList.remove("hidden"); + elements.historyView.classList.add("hidden"); + elements.settingsView.classList.add("hidden"); + } else if (view === "history") { + elements.intakeForm.classList.add("hidden"); + elements.todayView.classList.add("hidden"); + elements.historyView.classList.remove("hidden"); + elements.settingsView.classList.add("hidden"); + } else { + elements.intakeForm.classList.add("hidden"); + elements.todayView.classList.add("hidden"); + elements.historyView.classList.add("hidden"); + elements.settingsView.classList.remove("hidden"); + } + }); + }); +}; + +const loadFoods = async () => { + const response = await fetch(FOODS_URL); + const foods = await response.json(); + state.foods = foods.map((food) => ({ + ...food, + sugar: typeof food.sugar === "number" ? food.sugar : 0, + })); + elements.foodHint.textContent = `${state.foods.length} foods ready.`; + renderIntakePreview(); +}; + +const init = async () => { + loadIntakes(); + loadTheme(); + loadGoals(); + await loadFoods(); + state.selectedDate = toISODate(new Date()); + elements.anchorDate.value = toISODate(new Date()); + elements.todaySelectDate.value = state.selectedDate; + elements.entryDate.value = state.selectedDate; + applyTheme(); + renderGoalsForm(); + elements.goalsHint.textContent = ""; + renderDay(); + renderHistory(); +}; + +const registerServiceWorker = () => { + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("sw.js"); + }); + } +}; + +initTabs(); +loadIntakes(); +registerServiceWorker(); + +window.addEventListener("DOMContentLoaded", () => { + initFoodAutocomplete(); + elements.addForm.addEventListener("submit", addIntake); + elements.entryDate.addEventListener("change", (event) => { + state.selectedDate = event.target.value || state.selectedDate; + renderDay(); + }); + elements.todaySelectDate.addEventListener("change", (event) => { + state.selectedDate = event.target.value || state.selectedDate; + renderDay(); + }); + elements.rangeSelect.addEventListener("change", renderHistory); + elements.anchorDate.addEventListener("change", renderHistory); + elements.themeToggle.addEventListener("click", toggleTheme); + elements.exportDataButton.addEventListener("click", exportLocalData); + elements.importDataButton.addEventListener("click", () => { + elements.importFileInput.click(); + }); + elements.importFileInput.addEventListener("change", importLocalData); + elements.goalsForm.addEventListener("submit", saveGoalsFromSettings); + elements.foodInput.addEventListener("input", renderIntakePreview); + elements.gramsInput.addEventListener("input", renderIntakePreview); + document.addEventListener("click", (event) => { + if ( + event.target !== elements.foodInput && + !elements.foodSuggestions.contains(event.target) + ) { + hideFoodSuggestions(); + } + }); + init(); +});
A
foods.json
@@ -0,0 +1,1050 @@
+[ + { + "name": "Chicken breast", + "kcal": 165, + "protein": 31.0, + "carbs": 0.0, + "fat": 3.6, + "sugar": 0.0 + }, + { + "name": "Chicken thigh", + "kcal": 209, + "protein": 26.0, + "carbs": 0.0, + "fat": 10.9, + "sugar": 0.0 + }, + { + "name": "Turkey breast", + "kcal": 135, + "protein": 29.0, + "carbs": 0.0, + "fat": 1.0, + "sugar": 0.0 + }, + { + "name": "Lean beef", + "kcal": 217, + "protein": 26.1, + "carbs": 0.0, + "fat": 11.8, + "sugar": 0.0 + }, + { + "name": "Pork loin", + "kcal": 196, + "protein": 27.3, + "carbs": 0.0, + "fat": 8.3, + "sugar": 0.0 + }, + { + "name": "Salmon", + "kcal": 208, + "protein": 20.0, + "carbs": 0.0, + "fat": 13.0, + "sugar": 0.0 + }, + { + "name": "Tuna", + "kcal": 132, + "protein": 28.0, + "carbs": 0.0, + "fat": 1.3, + "sugar": 0.0 + }, + { + "name": "Shrimp", + "kcal": 99, + "protein": 24.0, + "carbs": 0.2, + "fat": 0.3, + "sugar": 0.0 + }, + { + "name": "Egg", + "kcal": 155, + "protein": 13.0, + "carbs": 1.1, + "fat": 11.0, + "sugar": 1.1 + }, + { + "name": "Egg whites", + "kcal": 52, + "protein": 10.9, + "carbs": 0.7, + "fat": 0.2, + "sugar": 0.7 + }, + { + "name": "Tofu", + "kcal": 144, + "protein": 17.3, + "carbs": 3.0, + "fat": 8.7, + "sugar": 0.6 + }, + { + "name": "Tempeh", + "kcal": 193, + "protein": 20.3, + "carbs": 9.4, + "fat": 10.8, + "sugar": 0.0 + }, + { + "name": "Greek yogurt", + "kcal": 59, + "protein": 10.0, + "carbs": 3.6, + "fat": 0.4, + "sugar": 3.6 + }, + { + "name": "Cottage cheese", + "kcal": 98, + "protein": 11.1, + "carbs": 3.4, + "fat": 4.3, + "sugar": 2.7 + }, + { + "name": "Milk 2%", + "kcal": 50, + "protein": 3.4, + "carbs": 4.8, + "fat": 2.0, + "sugar": 5.0 + }, + { + "name": "Milk whole", + "kcal": 61, + "protein": 3.2, + "carbs": 4.8, + "fat": 3.3, + "sugar": 5.1 + }, + { + "name": "Cheddar cheese", + "kcal": 403, + "protein": 25.0, + "carbs": 1.3, + "fat": 33.1, + "sugar": 0.5 + }, + { + "name": "Yogurt low-fat plain", + "kcal": 63, + "protein": 5.3, + "carbs": 7.0, + "fat": 1.6, + "sugar": 7.0 + }, + { + "name": "Yogurt whole plain", + "kcal": 61, + "protein": 3.5, + "carbs": 4.7, + "fat": 3.3, + "sugar": 4.7 + }, + { + "name": "Yogurt drinkable", + "kcal": 72, + "protein": 3.1, + "carbs": 9.4, + "fat": 2.6, + "sugar": 9.4 + }, + { + "name": "White rice", + "kcal": 130, + "protein": 2.7, + "carbs": 28.2, + "fat": 0.3, + "sugar": 0.1 + }, + { + "name": "Brown rice", + "kcal": 123, + "protein": 2.7, + "carbs": 25.6, + "fat": 1.0, + "sugar": 0.4 + }, + { + "name": "Jasmine rice", + "kcal": 129, + "protein": 2.9, + "carbs": 27.9, + "fat": 0.4, + "sugar": 0.1 + }, + { + "name": "Basmati rice", + "kcal": 121, + "protein": 3.5, + "carbs": 25.2, + "fat": 0.4, + "sugar": 0.1 + }, + { + "name": "Wild rice", + "kcal": 101, + "protein": 4.0, + "carbs": 21.3, + "fat": 0.3, + "sugar": 0.7 + }, + { + "name": "Quinoa", + "kcal": 120, + "protein": 4.4, + "carbs": 21.3, + "fat": 1.9, + "sugar": 0.9 + }, + { + "name": "Oats", + "kcal": 389, + "protein": 16.9, + "carbs": 66.3, + "fat": 6.9, + "sugar": 1.0 + }, + { + "name": "Whole wheat bread", + "kcal": 247, + "protein": 13.0, + "carbs": 41.2, + "fat": 4.2, + "sugar": 6.0 + }, + { + "name": "Sourdough bread", + "kcal": 289, + "protein": 9.4, + "carbs": 56.2, + "fat": 3.3, + "sugar": 0.7 + }, + { + "name": "Pasta (cooked)", + "kcal": 158, + "protein": 5.8, + "carbs": 30.9, + "fat": 0.9, + "sugar": 0.6 + }, + { + "name": "Beef burger patty", + "kcal": 254, + "protein": 17.0, + "carbs": 0.0, + "fat": 20.0, + "sugar": 0.0 + }, + { + "name": "Chicken burger patty", + "kcal": 215, + "protein": 17.0, + "carbs": 8.0, + "fat": 13.0, + "sugar": 0.8 + }, + { + "name": "Turkey burger patty", + "kcal": 203, + "protein": 19.0, + "carbs": 0.0, + "fat": 13.0, + "sugar": 0.0 + }, + { + "name": "Burger bun", + "kcal": 270, + "protein": 8.8, + "carbs": 50.0, + "fat": 3.8, + "sugar": 5.0 + }, + { + "name": "Cheeseburger", + "kcal": 295, + "protein": 17.0, + "carbs": 30.0, + "fat": 12.0, + "sugar": 5.0 + }, + { + "name": "Chicken burger", + "kcal": 243, + "protein": 13.5, + "carbs": 25.0, + "fat": 9.0, + "sugar": 4.3 + }, + { + "name": "Veggie burger", + "kcal": 177, + "protein": 12.0, + "carbs": 18.0, + "fat": 6.3, + "sugar": 3.0 + }, + { + "name": "Sweet potato", + "kcal": 86, + "protein": 1.6, + "carbs": 20.1, + "fat": 0.1, + "sugar": 4.2 + }, + { + "name": "Potato", + "kcal": 77, + "protein": 2.0, + "carbs": 17.5, + "fat": 0.1, + "sugar": 0.8 + }, + { + "name": "Black beans", + "kcal": 132, + "protein": 8.9, + "carbs": 23.7, + "fat": 0.5, + "sugar": 0.3 + }, + { + "name": "Chickpeas", + "kcal": 164, + "protein": 8.9, + "carbs": 27.4, + "fat": 2.6, + "sugar": 4.8 + }, + { + "name": "Lentils", + "kcal": 116, + "protein": 9.0, + "carbs": 20.1, + "fat": 0.4, + "sugar": 1.8 + }, + { + "name": "Peanut butter", + "kcal": 588, + "protein": 25.0, + "carbs": 20.0, + "fat": 50.0, + "sugar": 9.2 + }, + { + "name": "Almonds", + "kcal": 579, + "protein": 21.2, + "carbs": 21.7, + "fat": 49.9, + "sugar": 4.4 + }, + { + "name": "Walnuts", + "kcal": 654, + "protein": 15.2, + "carbs": 13.7, + "fat": 65.2, + "sugar": 2.6 + }, + { + "name": "Avocado", + "kcal": 160, + "protein": 2.0, + "carbs": 8.5, + "fat": 14.7, + "sugar": 0.7 + }, + { + "name": "Olive oil", + "kcal": 884, + "protein": 0.0, + "carbs": 0.0, + "fat": 100.0, + "sugar": 0.0 + }, + { + "name": "Butter", + "kcal": 717, + "protein": 0.9, + "carbs": 0.1, + "fat": 81.1, + "sugar": 0.1 + }, + { + "name": "Banana", + "kcal": 89, + "protein": 1.1, + "carbs": 22.8, + "fat": 0.3, + "sugar": 12.2 + }, + { + "name": "Apple", + "kcal": 52, + "protein": 0.3, + "carbs": 13.8, + "fat": 0.2, + "sugar": 10.4 + }, + { + "name": "Blueberries", + "kcal": 57, + "protein": 0.7, + "carbs": 14.5, + "fat": 0.3, + "sugar": 10.0 + }, + { + "name": "Strawberries", + "kcal": 32, + "protein": 0.7, + "carbs": 7.7, + "fat": 0.3, + "sugar": 4.9 + }, + { + "name": "Orange", + "kcal": 47, + "protein": 0.9, + "carbs": 11.8, + "fat": 0.1, + "sugar": 9.4 + }, + { + "name": "Broccoli", + "kcal": 34, + "protein": 2.8, + "carbs": 6.6, + "fat": 0.4, + "sugar": 1.7 + }, + { + "name": "Spinach", + "kcal": 23, + "protein": 2.9, + "carbs": 3.6, + "fat": 0.4, + "sugar": 0.4 + }, + { + "name": "Carrot", + "kcal": 41, + "protein": 0.9, + "carbs": 9.6, + "fat": 0.2, + "sugar": 4.7 + }, + { + "name": "Bell pepper", + "kcal": 31, + "protein": 1.0, + "carbs": 6.0, + "fat": 0.3, + "sugar": 4.2 + }, + { + "name": "Tomato", + "kcal": 18, + "protein": 0.9, + "carbs": 3.9, + "fat": 0.2, + "sugar": 2.6 + }, + { + "name": "Cucumber", + "kcal": 15, + "protein": 0.7, + "carbs": 3.6, + "fat": 0.1, + "sugar": 1.7 + }, + { + "name": "Milk fat-free", + "kcal": 34, + "protein": 3.4, + "carbs": 5.0, + "fat": 0.1, + "sugar": 5.0 + }, + { + "name": "Skyr", + "kcal": 63, + "protein": 11.0, + "carbs": 3.8, + "fat": 0.2, + "sugar": 3.8 + }, + { + "name": "Ricotta light", + "kcal": 138, + "protein": 11.0, + "carbs": 3.0, + "fat": 7.0, + "sugar": 0.3 + }, + { + "name": "Mozzarella light", + "kcal": 254, + "protein": 24.3, + "carbs": 2.8, + "fat": 15.9, + "sugar": 1.0 + }, + { + "name": "Protein powder", + "kcal": 376, + "protein": 77.5, + "carbs": 6.7, + "fat": 4.2, + "sugar": 2.0 + }, + { + "name": "Cod", + "kcal": 82, + "protein": 18.0, + "carbs": 0.0, + "fat": 0.7, + "sugar": 0.0 + }, + { + "name": "Tilapia", + "kcal": 96, + "protein": 20.1, + "carbs": 0.0, + "fat": 1.7, + "sugar": 0.0 + }, + { + "name": "Ground turkey 93/7", + "kcal": 176, + "protein": 23.0, + "carbs": 0.0, + "fat": 9.0, + "sugar": 0.0 + }, + { + "name": "Turkey ham", + "kcal": 104, + "protein": 17.0, + "carbs": 2.0, + "fat": 3.0, + "sugar": 1.0 + }, + { + "name": "Edamame", + "kcal": 122, + "protein": 11.9, + "carbs": 8.9, + "fat": 5.2, + "sugar": 2.2 + }, + { + "name": "Kidney beans", + "kcal": 127, + "protein": 8.7, + "carbs": 22.8, + "fat": 0.5, + "sugar": 0.3 + }, + { + "name": "Rice cakes", + "kcal": 387, + "protein": 7.8, + "carbs": 81.5, + "fat": 3.0, + "sugar": 0.3 + }, + { + "name": "Whole wheat tortilla", + "kcal": 218, + "protein": 6.4, + "carbs": 40.0, + "fat": 4.9, + "sugar": 1.4 + }, + { + "name": "Couscous (cooked)", + "kcal": 112, + "protein": 3.8, + "carbs": 23.2, + "fat": 0.2, + "sugar": 0.1 + }, + { + "name": "Buckwheat (cooked)", + "kcal": 92, + "protein": 3.4, + "carbs": 19.9, + "fat": 0.6, + "sugar": 0.9 + }, + { + "name": "Cauliflower rice", + "kcal": 25, + "protein": 1.9, + "carbs": 5.0, + "fat": 0.3, + "sugar": 2.4 + }, + { + "name": "Asparagus", + "kcal": 20, + "protein": 2.2, + "carbs": 3.9, + "fat": 0.1, + "sugar": 1.9 + }, + { + "name": "Zucchini", + "kcal": 17, + "protein": 1.2, + "carbs": 3.1, + "fat": 0.3, + "sugar": 2.5 + }, + { + "name": "Green beans", + "kcal": 31, + "protein": 1.8, + "carbs": 7.0, + "fat": 0.1, + "sugar": 3.3 + }, + { + "name": "Mushrooms", + "kcal": 22, + "protein": 3.1, + "carbs": 3.3, + "fat": 0.3, + "sugar": 2.0 + }, + { + "name": "Lettuce", + "kcal": 15, + "protein": 1.4, + "carbs": 2.9, + "fat": 0.2, + "sugar": 0.8 + }, + { + "name": "Cabbage", + "kcal": 25, + "protein": 1.3, + "carbs": 5.8, + "fat": 0.1, + "sugar": 3.2 + }, + { + "name": "Onion", + "kcal": 40, + "protein": 1.1, + "carbs": 9.3, + "fat": 0.1, + "sugar": 4.2 + }, + { + "name": "Kiwi", + "kcal": 61, + "protein": 1.1, + "carbs": 14.7, + "fat": 0.5, + "sugar": 9.0 + }, + { + "name": "Pineapple", + "kcal": 50, + "protein": 0.5, + "carbs": 13.1, + "fat": 0.1, + "sugar": 9.9 + }, + { + "name": "Grapes", + "kcal": 69, + "protein": 0.7, + "carbs": 18.1, + "fat": 0.2, + "sugar": 15.5 + }, + { + "name": "Watermelon", + "kcal": 30, + "protein": 0.6, + "carbs": 7.6, + "fat": 0.2, + "sugar": 6.2 + }, + { + "name": "Chia seeds", + "kcal": 486, + "protein": 16.5, + "carbs": 42.1, + "fat": 30.7, + "sugar": 0.0 + }, + { + "name": "Flaxseed", + "kcal": 534, + "protein": 18.3, + "carbs": 28.9, + "fat": 42.2, + "sugar": 1.6 + }, + { + "name": "Pumpkin seeds", + "kcal": 559, + "protein": 30.2, + "carbs": 10.7, + "fat": 49.0, + "sugar": 1.4 + }, + { + "name": "Almond milk unsweetened", + "kcal": 15, + "protein": 0.6, + "carbs": 0.3, + "fat": 1.2, + "sugar": 0.2 + }, + { + "name": "Soy milk unsweetened", + "kcal": 33, + "protein": 3.3, + "carbs": 0.7, + "fat": 1.8, + "sugar": 0.4 + }, + { + "name": "Kefir low-fat", + "kcal": 52, + "protein": 3.5, + "carbs": 5.0, + "fat": 1.0, + "sugar": 5.0 + }, + { + "name": "Eggs", + "kcal": 155, + "protein": 13.0, + "carbs": 1.1, + "fat": 11.0, + "sugar": 1.1 + }, + { + "name": "Smoked salmon", + "kcal": 117, + "protein": 18.3, + "carbs": 0.0, + "fat": 4.3, + "sugar": 0.0 + }, + { + "name": "Chicken thighs", + "kcal": 209, + "protein": 26.0, + "carbs": 0.0, + "fat": 10.9, + "sugar": 0.0 + }, + { + "name": "Grilled chicken", + "kcal": 165, + "protein": 31.0, + "carbs": 0.0, + "fat": 3.6, + "sugar": 0.0 + }, + { + "name": "Beef mince", + "kcal": 254, + "protein": 17.2, + "carbs": 0.0, + "fat": 20.0, + "sugar": 0.0 + }, + { + "name": "Steak", + "kcal": 271, + "protein": 25.0, + "carbs": 0.0, + "fat": 19.0, + "sugar": 0.0 + }, + { + "name": "Beef patties", + "kcal": 254, + "protein": 17.0, + "carbs": 0.0, + "fat": 20.0, + "sugar": 0.0 + }, + { + "name": "Turkey mince", + "kcal": 203, + "protein": 19.0, + "carbs": 0.0, + "fat": 13.0, + "sugar": 0.0 + }, + { + "name": "Pork tenderloin", + "kcal": 143, + "protein": 26.2, + "carbs": 0.0, + "fat": 3.5, + "sugar": 0.0 + }, + { + "name": "Lamb chops", + "kcal": 294, + "protein": 24.5, + "carbs": 0.0, + "fat": 21.0, + "sugar": 0.0 + }, + { + "name": "Sardines", + "kcal": 208, + "protein": 24.6, + "carbs": 0.0, + "fat": 11.5, + "sugar": 0.0 + }, + { + "name": "Mackerel", + "kcal": 205, + "protein": 18.6, + "carbs": 0.0, + "fat": 13.9, + "sugar": 0.0 + }, + { + "name": "Full-fat Greek yogurt", + "kcal": 97, + "protein": 9.0, + "carbs": 3.9, + "fat": 5.0, + "sugar": 3.6 + }, + { + "name": "Whey isolate", + "kcal": 370, + "protein": 86.0, + "carbs": 2.0, + "fat": 1.0, + "sugar": 1.0 + }, + { + "name": "Cheese", + "kcal": 402, + "protein": 25.0, + "carbs": 1.3, + "fat": 33.0, + "sugar": 0.5 + }, + { + "name": "Feta cheese", + "kcal": 264, + "protein": 14.2, + "carbs": 4.1, + "fat": 21.3, + "sugar": 4.1 + }, + { + "name": "Parmesan", + "kcal": 431, + "protein": 38.5, + "carbs": 4.1, + "fat": 28.6, + "sugar": 0.9 + }, + { + "name": "Cream cheese", + "kcal": 342, + "protein": 5.9, + "carbs": 4.1, + "fat": 34.2, + "sugar": 3.2 + }, + { + "name": "Extra-virgin olive oil", + "kcal": 884, + "protein": 0.0, + "carbs": 0.0, + "fat": 100.0, + "sugar": 0.0 + }, + { + "name": "Mayonnaise", + "kcal": 680, + "protein": 1.0, + "carbs": 0.6, + "fat": 75.0, + "sugar": 0.6 + }, + { + "name": "Coconut milk", + "kcal": 230, + "protein": 2.3, + "carbs": 5.5, + "fat": 23.8, + "sugar": 3.3 + }, + { + "name": "Macadamia nuts", + "kcal": 718, + "protein": 7.9, + "carbs": 13.8, + "fat": 75.8, + "sugar": 4.6 + }, + { + "name": "Olives", + "kcal": 115, + "protein": 0.8, + "carbs": 6.3, + "fat": 10.7, + "sugar": 0.0 + }, + { + "name": "Mixed greens", + "kcal": 20, + "protein": 2.0, + "carbs": 3.3, + "fat": 0.2, + "sugar": 0.8 + }, + { + "name": "Cauliflower", + "kcal": 25, + "protein": 1.9, + "carbs": 5.0, + "fat": 0.3, + "sugar": 1.9 + }, + { + "name": "Celery", + "kcal": 16, + "protein": 0.7, + "carbs": 3.0, + "fat": 0.2, + "sugar": 1.3 + }, + { + "name": "Pickles", + "kcal": 12, + "protein": 0.5, + "carbs": 2.4, + "fat": 0.2, + "sugar": 1.1 + }, + { + "name": "Tomato sauce", + "kcal": 29, + "protein": 1.3, + "carbs": 6.6, + "fat": 0.2, + "sugar": 4.8 + }, + { + "name": "Low-sugar tomato sauce", + "kcal": 24, + "protein": 1.2, + "carbs": 4.3, + "fat": 0.2, + "sugar": 2.5 + }, + { + "name": "Pesto", + "kcal": 458, + "protein": 5.0, + "carbs": 6.0, + "fat": 46.0, + "sugar": 1.0 + }, + { + "name": "Guacamole", + "kcal": 150, + "protein": 2.0, + "carbs": 8.0, + "fat": 13.0, + "sugar": 1.0 + }, + { + "name": "Lemon", + "kcal": 29, + "protein": 1.1, + "carbs": 9.3, + "fat": 0.3, + "sugar": 2.5 + }, + { + "name": "Mustard", + "kcal": 66, + "protein": 4.4, + "carbs": 5.8, + "fat": 3.3, + "sugar": 1.2 + }, + { + "name": "Cinnamon", + "kcal": 247, + "protein": 4.0, + "carbs": 80.6, + "fat": 1.2, + "sugar": 2.2 + }, + { + "name": "Curry spices", + "kcal": 325, + "protein": 14.3, + "carbs": 55.8, + "fat": 14.0, + "sugar": 2.8 + }, + { + "name": "Salt", + "kcal": 0, + "protein": 0.0, + "carbs": 0.0, + "fat": 0.0, + "sugar": 0.0 + }, + { + "name": "Pepper", + "kcal": 251, + "protein": 10.4, + "carbs": 63.9, + "fat": 3.3, + "sugar": 0.6 + }, + { + "name": "Broth", + "kcal": 7, + "protein": 1.0, + "carbs": 0.4, + "fat": 0.2, + "sugar": 0.0 + }, + { + "name": "Bouillon", + "kcal": 267, + "protein": 17.4, + "carbs": 35.0, + "fat": 4.0, + "sugar": 16.0 + } +]
A
icons/icon-192.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 256 256"> + <defs> + <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stop-color="#0f766e" /> + <stop offset="100%" stop-color="#14b8a6" /> + </linearGradient> + <linearGradient id="bars" x1="0%" y1="0%" x2="0%" y2="100%"> + <stop offset="0%" stop-color="#f8fafc" /> + <stop offset="100%" stop-color="#dbeafe" /> + </linearGradient> + </defs> + <rect width="256" height="256" rx="56" fill="url(#bg)" /> + <circle cx="188" cy="68" r="24" fill="#ecfeff" opacity="0.95" /> + <path d="M58 178V80c0-10 8-18 18-18h16c12 0 21 6 28 18l24 40 24-40c7-12 16-18 28-18h16c10 0 18 8 18 18v98c0 10-8 18-18 18h-16c-10 0-18-8-18-18v-40l-20 33c-5 8-11 12-18 12s-13-4-18-12l-20-33v40c0 10-8 18-18 18H76c-10 0-18-8-18-18z" fill="#083344" opacity="0.25" /> + <path d="M54 174V76c0-10 8-18 18-18h16c12 0 21 6 28 18l24 40 24-40c7-12 16-18 28-18h16c10 0 18 8 18 18v98c0 10-8 18-18 18h-16c-10 0-18-8-18-18v-40l-20 33c-5 8-11 12-18 12s-13-4-18-12l-20-33v40c0 10-8 18-18 18H72c-10 0-18-8-18-18z" fill="url(#bars)" /> + <rect x="48" y="196" width="160" height="14" rx="7" fill="#ecfeff" opacity="0.7" /> + <rect x="48" y="196" width="112" height="14" rx="7" fill="#f59e0b" /> + <rect x="48" y="196" width="78" height="14" rx="7" fill="#22c55e" /> +</svg>
A
icons/icon-512.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 256 256"> + <defs> + <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stop-color="#0f766e" /> + <stop offset="100%" stop-color="#14b8a6" /> + </linearGradient> + <linearGradient id="bars" x1="0%" y1="0%" x2="0%" y2="100%"> + <stop offset="0%" stop-color="#f8fafc" /> + <stop offset="100%" stop-color="#dbeafe" /> + </linearGradient> + </defs> + <rect width="256" height="256" rx="56" fill="url(#bg)" /> + <circle cx="188" cy="68" r="24" fill="#ecfeff" opacity="0.95" /> + <path d="M58 178V80c0-10 8-18 18-18h16c12 0 21 6 28 18l24 40 24-40c7-12 16-18 28-18h16c10 0 18 8 18 18v98c0 10-8 18-18 18h-16c-10 0-18-8-18-18v-40l-20 33c-5 8-11 12-18 12s-13-4-18-12l-20-33v40c0 10-8 18-18 18H76c-10 0-18-8-18-18z" fill="#083344" opacity="0.25" /> + <path d="M54 174V76c0-10 8-18 18-18h16c12 0 21 6 28 18l24 40 24-40c7-12 16-18 28-18h16c10 0 18 8 18 18v98c0 10-8 18-18 18h-16c-10 0-18-8-18-18v-40l-20 33c-5 8-11 12-18 12s-13-4-18-12l-20-33v40c0 10-8 18-18 18H72c-10 0-18-8-18-18z" fill="url(#bars)" /> + <rect x="48" y="196" width="160" height="14" rx="7" fill="#ecfeff" opacity="0.7" /> + <rect x="48" y="196" width="112" height="14" rx="7" fill="#f59e0b" /> + <rect x="48" y="196" width="78" height="14" rx="7" fill="#22c55e" /> +</svg>
A
icons/logo.svg
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="macros logo"> + <defs> + <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stop-color="#0f766e" /> + <stop offset="100%" stop-color="#14b8a6" /> + </linearGradient> + <linearGradient id="bars" x1="0%" y1="0%" x2="0%" y2="100%"> + <stop offset="0%" stop-color="#f8fafc" /> + <stop offset="100%" stop-color="#dbeafe" /> + </linearGradient> + </defs> + <rect width="256" height="256" rx="56" fill="url(#bg)" /> + <circle cx="188" cy="68" r="24" fill="#ecfeff" opacity="0.95" /> + <path d="M58 178V80c0-10 8-18 18-18h16c12 0 21 6 28 18l24 40 24-40c7-12 16-18 28-18h16c10 0 18 8 18 18v98c0 10-8 18-18 18h-16c-10 0-18-8-18-18v-40l-20 33c-5 8-11 12-18 12s-13-4-18-12l-20-33v40c0 10-8 18-18 18H76c-10 0-18-8-18-18z" fill="#083344" opacity="0.25" /> + <path d="M54 174V76c0-10 8-18 18-18h16c12 0 21 6 28 18l24 40 24-40c7-12 16-18 28-18h16c10 0 18 8 18 18v98c0 10-8 18-18 18h-16c-10 0-18-8-18-18v-40l-20 33c-5 8-11 12-18 12s-13-4-18-12l-20-33v40c0 10-8 18-18 18H72c-10 0-18-8-18-18z" fill="url(#bars)" /> + <rect x="48" y="196" width="160" height="14" rx="7" fill="#ecfeff" opacity="0.7" /> + <rect x="48" y="196" width="112" height="14" rx="7" fill="#f59e0b" /> + <rect x="48" y="196" width="78" height="14" rx="7" fill="#22c55e" /> +</svg>
A
index.html
@@ -0,0 +1,215 @@
+<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="theme-color" content="#0f766e" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-status-bar-style" content="default" /> + <meta name="apple-mobile-web-app-title" content="macros" /> + <title>macros</title> + <link rel="manifest" href="manifest.json" /> + <link rel="icon" href="icons/logo.svg" type="image/svg+xml" /> + <link rel="apple-touch-icon" href="icons/logo.svg" /> + <link rel="stylesheet" href="styles.css" /> + </head> + <body> + <div class="app"> + <header class="topbar"> + <div class="brand"> + <img class="brand-logo" src="icons/logo.svg" alt="macros logo" /> + <div> + <h1>macros</h1> + <p>Lightweight food intake tracker</p> + </div> + </div> + <div class="topbar-actions"> + <button id="theme-toggle" class="ghost icon-button" type="button" aria-label="Switch to dark mode" title="Switch to dark mode"> + <span class="theme-icon icon-moon" aria-hidden="true"> + <svg viewBox="0 0 24 24" focusable="false"> + <path d="M15 2.8c-2.2.8-3.8 2.9-3.8 5.5 0 3.3 2.7 6 6 6 1.4 0 2.6-.4 3.7-1.2-1 4.7-5.1 8.2-10.1 8.2-5.7 0-10.3-4.6-10.3-10.3 0-5.3 4-9.7 9.2-10.3.2 0 .4 0 .5-.1H15z"></path> + </svg> + </span> + <span class="theme-icon icon-sun" aria-hidden="true"> + <svg viewBox="0 0 24 24" focusable="false"> + <path d="M12 7a5 5 0 100 10 5 5 0 000-10zm0-5a1 1 0 011 1v2a1 1 0 11-2 0V3a1 1 0 011-1zm0 17a1 1 0 011 1v2a1 1 0 11-2 0v-2a1 1 0 011-1zm9-8a1 1 0 110 2h-2a1 1 0 110-2h2zM5 12a1 1 0 110 2H3a1 1 0 110-2h2zm12.4-6.4a1 1 0 011.4 1.4l-1.4 1.4A1 1 0 0116 7.6l1.4-1.4zM8 16a1 1 0 011.4 1.4L8 18.8a1 1 0 01-1.4-1.4L8 16zm10.8 1.4a1 1 0 01-1.4 1.4L16 17.4a1 1 0 011.4-1.4l1.4 1.4zM9.4 8a1 1 0 01-1.4 1.4L6.6 8A1 1 0 018 6.6L9.4 8z"></path> + </svg> + </span> + </button> + <nav class="nav"> + <button class="tab active" data-view="today">Today</button> + <button class="tab" data-view="history">History</button> + <button class="tab" data-view="settings">Settings</button> + </nav> + </div> + </header> + + <main> + <section class="card" id="intake-form"> + <div class="card-header"> + <h2>Add Intake</h2> + <p>Pick a food and log the grams you ate.</p> + </div> + <form id="add-intake"> + <label> + Food + <div class="food-combobox"> + <input name="food" id="food-input" placeholder="Start typing..." autocomplete="off" required /> + <div id="food-suggestions" class="food-suggestions hidden"></div> + </div> + </label> + <label> + Amount (grams) + <input type="number" min="1" step="1" name="grams" id="grams-input" value="100" required /> + </label> + <label> + Log date + <input type="date" name="entry-date" id="entry-date" /> + </label> + <button type="submit" class="primary">Add</button> + </form> + <div id="intake-preview" class="intake-preview"> + <p class="preview-title">Nutrition preview</p> + <div class="preview-grid"> + <div class="preview-block"> + <p class="preview-block-title">Per 100g</p> + <p id="preview-base" class="preview-values">Select a food to preview macros.</p> + </div> + <div class="preview-block"> + <p class="preview-block-title">For selected amount</p> + <p id="preview-scaled" class="preview-values">Enter grams to see calculated macros.</p> + </div> + </div> + </div> + <div class="hint" id="food-hint">Loading foods...</div> + </section> + + <section class="card" id="today-view"> + <div class="card-header"> + <h2>Day</h2> + <p id="today-date"></p> + </div> + <div class="history-controls"> + <label> + Select day + <input type="date" id="today-select-date" /> + </label> + </div> + <div class="totals" id="today-totals"></div> + <div class="list" id="today-list"></div> + </section> + + <section class="card hidden" id="history-view"> + <div class="card-header"> + <h2>History</h2> + <p>Zoom by day, week, month, or quarter.</p> + </div> + <div class="history-controls"> + <label> + Range + <select id="range-select"> + <option value="day">Day</option> + <option value="week" selected>Week</option> + <option value="month">Month</option> + <option value="quarter">Quarter</option> + </select> + </label> + <label> + Anchor date + <input type="date" id="anchor-date" /> + </label> + </div> + <div class="history-charts"> + <div class="chart-card" id="kcal-chart-card"> + <h3>Calories by day</h3> + <div id="history-kcal-chart" class="chart-grid"></div> + </div> + <div class="chart-card" id="macro-chart-card"> + <h3>Macro split by day</h3> + <div id="history-macro-chart" class="chart-grid"></div> + <div class="chart-legend"> + <span><i class="legend-dot protein"></i>Protein</span> + <span><i class="legend-dot carbs"></i>Carbs</span> + <span><i class="legend-dot fat"></i>Fat</span> + </div> + </div> + </div> + <div class="totals" id="history-totals"></div> + <div class="list" id="history-breakdown"></div> + </section> + + <section class="card hidden" id="settings-view"> + <div class="card-header"> + <h2>Settings</h2> + <p>Data migration and daily goals.</p> + </div> + <p class="hint"> + This app is a PWA, so you can install it on your phone from the browser menu (Add to Home Screen / Install app). All your data stays locally on your device only and is never sent to any server. + </p> + <br> + <div class="settings-panels"> + <div class="settings-panel"> + <h3>Data migration</h3> + <div class="settings-actions"> + <button id="export-data" class="ghost" type="button">Export local data</button> + <button id="import-data" class="ghost" type="button">Import local data</button> + <input id="import-file" type="file" accept="application/json" class="file-input-hidden" /> + </div> + </div> + <div class="settings-panel"> + <h3>Daily goals</h3> + <form id="goals-form" class="goals-form"> + <label> + Calories (kcal) + <input type="number" min="0" step="1" id="goal-kcal" required /> + </label> + <label> + Protein (g) + <input type="number" min="0" step="1" id="goal-protein" required /> + </label> + <label> + Carbs (g) + <input type="number" min="0" step="1" id="goal-carbs" required /> + </label> + <label> + Fat (g) + <input type="number" min="0" step="1" id="goal-fat" required /> + </label> + <label> + Sugar (g) + <input type="number" min="0" step="1" id="goal-sugar" required /> + </label> + <button type="submit" class="primary">Save goals</button> + </form> + <p class="hint" id="goals-hint"></p> + </div> + </div> + </section> + </main> + </div> + + <template id="intake-row"> + <div class="row"> + <div> + <div class="row-title"></div> + <div class="row-sub"></div> + </div> + <div class="row-actions"> + <div class="row-kcal"></div> + <button type="button" class="row-edit hidden icon-action" aria-label="Edit entry" title="Edit entry"> + <svg viewBox="0 0 24 24" focusable="false" aria-hidden="true"> + <path d="M3 17.25V21h3.75L17.8 9.94l-3.75-3.75L3 17.25zm2.92 2.33H5v-.92l9.06-9.06.92.92L5.92 19.58zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.13 1.13 3.75 3.75 1.13-1.13z"></path> + </svg> + </button> + <button type="button" class="row-delete hidden icon-action" aria-label="Delete entry" title="Delete entry"> + <svg viewBox="0 0 24 24" focusable="false" aria-hidden="true"> + <path d="M6 7h12l-1 13a2 2 0 01-2 2H9a2 2 0 01-2-2L6 7zm3-4h6l1 2h4v2H4V5h4l1-2zm1 6h2v9h-2V9zm4 0h2v9h-2V9z"></path> + </svg> + </button> + </div> + </div> + </template> + + <script src="app.js"></script> + </body> +</html>
A
manifest.json
@@ -0,0 +1,24 @@
+{ + "name": "macros", + "short_name": "macros", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f6f7f5", + "theme_color": "#0f766e", + "icons": [ + { + "src": "icons/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "icons/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +}
A
styles.css
@@ -0,0 +1,761 @@
+:root { + color-scheme: light; + --bg: #f6f7f5; + --panel: #ffffff; + --ink: #0f172a; + --muted: #64748b; + --accent: #0f766e; + --accent-soft: #e6f5f1; + --border: #e2e8f0; + --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + --field-bg: #f8fafc; + --radius: 18px; + --font: "Space Grotesk", "Montserrat", "Poppins", system-ui, sans-serif; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #09111a; + --panel: #121b27; + --ink: #e6edf5; + --muted: #9dafc5; + --accent: #14b8a6; + --accent-soft: #16303a; + --border: #263546; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + --field-bg: #1a2736; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + background: radial-gradient(circle at top, #ffffff 0%, #f3f4f2 45%, #eef2f1 100%); + color: var(--ink); + min-height: 100vh; +} + +:root[data-theme="dark"] body { + background: radial-gradient(circle at top, #122131 0%, #0b141d 45%, #09111a 100%); +} + +.app { + max-width: 960px; + margin: 32px auto 48px; + padding: 0 20px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + margin-bottom: 24px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.brand { + display: flex; + align-items: center; + gap: 16px; +} + +.brand-logo { + width: 44px; + height: 44px; + border-radius: 14px; + box-shadow: var(--shadow); + display: block; +} + +.brand h1 { + font-size: 1.6rem; + letter-spacing: -0.02em; +} + +.brand p { + color: var(--muted); + font-size: 0.9rem; +} + +.nav { + display: flex; + gap: 8px; + background: var(--panel); + padding: 6px; + border-radius: 999px; + border: 1px solid var(--border); +} + +.ghost { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 14px; + font-weight: 600; + cursor: pointer; +} + +.icon-button { + width: 40px; + height: 40px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.theme-icon { + width: 18px; + height: 18px; + display: inline-flex; +} + +.theme-icon svg { + width: 100%; + height: 100%; + fill: currentColor; +} + +.icon-sun { + display: none; +} + +:root[data-theme="dark"] .icon-sun { + display: inline-flex; +} + +:root[data-theme="dark"] .icon-moon { + display: none; +} + +.file-input-hidden { + display: none; +} + +.tab { + border: none; + background: transparent; + padding: 8px 18px; + border-radius: 999px; + font-weight: 600; + color: var(--muted); + cursor: pointer; + transition: all 160ms ease; +} + +.tab.active { + color: var(--ink); + background: var(--accent-soft); +} + +main { + display: grid; + gap: 20px; +} + +.card { + background: var(--panel); + border-radius: var(--radius); + padding: 20px; + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 16px; + gap: 12px; +} + +.card-header p { + color: var(--muted); + font-size: 0.9rem; +} + +form { + display: grid; + gap: 14px; + grid-template-columns: 1.4fr 1fr 1fr auto; + align-items: end; +} + +label { + display: grid; + gap: 6px; + font-size: 0.85rem; + color: var(--muted); +} + +input, +select { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + font-size: 0.95rem; + background: var(--field-bg); + color: var(--ink); +} + +.food-combobox { + position: relative; +} + +.food-suggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--border); + background: var(--panel); + border-radius: 12px; + box-shadow: var(--shadow); + z-index: 5; + padding: 4px; +} + +.food-suggestion { + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--ink); + padding: 10px 11px; + border-radius: 9px; + cursor: pointer; +} + +.food-suggestion:hover, +.food-suggestion.active { + background: var(--accent-soft); +} + +button.primary { + background: var(--accent); + color: white; + border: none; + border-radius: 12px; + padding: 10px 18px; + font-weight: 600; + cursor: pointer; + transition: transform 140ms ease, box-shadow 140ms ease; +} + +button.primary:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(15, 118, 110, 0.25); +} + +.hint { + margin-top: 10px; + color: var(--muted); + font-size: 0.85rem; +} + +.intake-preview { + margin-top: 12px; + border: 1px solid var(--border); + background: var(--field-bg); + border-radius: 14px; + padding: 12px; +} + +.preview-title { + font-size: 0.8rem; + color: var(--muted); + margin-bottom: 8px; +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.preview-block { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; +} + +.preview-block-title { + font-size: 0.75rem; + color: var(--muted); + margin-bottom: 6px; +} + +.preview-values { + font-size: 0.86rem; + line-height: 1.35; +} + +.totals { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.total-card { + padding: 12px 14px; + border-radius: 14px; + background: var(--field-bg); + border: 1px solid var(--border); +} + +.total-card strong { + display: inline-flex; + align-items: baseline; + flex-wrap: nowrap; + font-size: 1.2rem; + line-height: 1.1; + white-space: nowrap; +} + +.goal-soft { + display: inline-block; + color: var(--muted); + font-weight: inherit; + opacity: 0.85; +} + +.total-unit { + display: inline-block; + margin-left: 4px; + font-size: 0.7em; + font-weight: 600; + color: var(--muted); +} + +.total-card span { + display: block; + color: var(--muted); + font-size: 0.8rem; +} + +.list { + display: grid; + gap: 10px; +} + +.history-day-group { + display: grid; + gap: 8px; +} + +.history-day-group + .history-day-group { + margin-top: 10px; +} + +.history-day-title { + font-size: 0.82rem; + color: var(--muted); + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + margin: 2px 2px 0; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + background: var(--field-bg); + border: 1px solid var(--border); +} + +.row-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.row-title { + font-weight: 600; +} + +.row-sub { + color: var(--muted); + font-size: 0.8rem; + margin-top: 2px; +} + +.row-kcal { + font-weight: 700; +} + +.row-delete { + border: 1px solid var(--border); + background: transparent; + color: var(--ink); + border-radius: 999px; + width: 34px; + height: 34px; + padding: 0; + cursor: pointer; +} + +.row-edit { + border: 1px solid var(--border); + background: transparent; + color: var(--ink); + border-radius: 999px; + width: 34px; + height: 34px; + padding: 0; + cursor: pointer; +} + +.icon-action { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon-action svg { + width: 16px; + height: 16px; + fill: currentColor; + display: block; +} + +.row-edit:hover { + color: var(--accent); + border-color: var(--accent); +} + +.row-delete:hover { + color: #ef4444; + border-color: #ef4444; +} + +.history-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.history-charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.chart-card { + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; + position: relative; +} + +.chart-card h3 { + font-size: 0.9rem; + margin-bottom: 10px; +} + +.chart-grid { + min-height: 170px; +} + +#history-kcal-chart { + display: block; +} + +#history-macro-chart { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(26px, 1fr); + gap: 8px; + overflow-x: auto; + padding-bottom: 4px; +} + +.chart-col { + display: grid; + grid-template-rows: auto 1fr auto; + align-items: end; + min-width: 26px; +} + +.chart-value { + font-size: 0.7rem; + color: var(--muted); + text-align: center; + margin-bottom: 4px; +} + +.chart-track { + height: 110px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel); + position: relative; + overflow: hidden; +} + +.chart-fill { + position: absolute; + left: 0; + right: 0; + bottom: 0; + border-radius: 8px; +} + +.chart-fill.kcal { + background: linear-gradient(180deg, #14b8a6, #0f766e); +} + +.chart-track.stacked { + display: flex; + flex-direction: column-reverse; + justify-content: flex-start; + align-self: end; + height: 110px; +} + +.chart-seg { + width: 100%; +} + +.chart-seg.protein, +.legend-dot.protein { + background: #22c55e; +} + +.chart-seg.carbs, +.legend-dot.carbs { + background: #f59e0b; +} + +.chart-seg.fat, +.legend-dot.fat { + background: #ef4444; +} + +.chart-label { + font-size: 0.68rem; + color: var(--muted); + text-align: center; + margin-top: 5px; + white-space: nowrap; +} + +.chart-legend { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; + font-size: 0.75rem; + color: var(--muted); +} + +.chart-legend span { + display: flex; + align-items: center; + gap: 5px; +} + +.line-chart-scroll { + overflow-x: auto; + padding-bottom: 2px; +} + +.line-chart-svg { + display: block; + width: 100%; + min-width: 320px; + height: 180px; +} + +.line-axis { + stroke: var(--border); + stroke-width: 1; +} + +.line-path { + fill: none; + stroke: var(--accent); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.goal-line { + stroke: #f59e0b; + stroke-width: 1.5; + stroke-dasharray: 6 4; +} + +.line-point { + fill: var(--accent); + stroke: var(--panel); + stroke-width: 2; +} + +.line-point.goal-under { + fill: #ef4444; +} + +.line-point.goal-over { + fill: #22c55e; +} + +.line-chart-labels { + display: flex; + justify-content: space-between; + margin-top: 4px; + color: var(--muted); + font-size: 0.72rem; +} + +.chart-tooltip { + position: absolute; + z-index: 3; + max-width: 180px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--ink); + font-size: 0.72rem; + pointer-events: none; + box-shadow: var(--shadow); +} + +.goal-indicators { + display: grid; + gap: 8px; +} + +.goal-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 10px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); +} + +.goal-row strong { + font-size: 0.86rem; +} + +.goal-row > span { + font-size: 0.75rem; + color: var(--muted); +} + +.goal-chip { + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 0.72rem; +} + +.goal-chip.under { + color: #dc2626; +} + +.goal-chip.on { + color: #16a34a; +} + +.goal-chip.over { + color: #0369a1; +} + +.settings-panels { + display: grid; + gap: 14px; +} + +.settings-panel { + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; +} + +.settings-panel h3 { + font-size: 0.95rem; + margin-bottom: 10px; +} + +.settings-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.goals-form { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.legend-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; +} + +.hidden { + display: none; +} + +@media (max-width: 720px) { + .topbar { + flex-direction: column; + align-items: flex-start; + } + + .topbar-actions { + width: 100%; + justify-content: space-between; + gap: 10px; + } + + .nav { + flex: 1; + justify-content: center; + } + + form { + grid-template-columns: 1fr; + } + + .goals-form { + grid-template-columns: 1fr; + } + + .settings-actions .ghost { + width: 100%; + } + + .preview-grid { + grid-template-columns: 1fr; + } +}
A
sw.js
@@ -0,0 +1,50 @@
+const CACHE_NAME = "macros-v12"; +const ASSETS = [ + "/", + "/index.html", + "/styles.css", + "/app.js", + "/foods.json", + "/manifest.json", + "/icons/logo.svg", + "/icons/icon-192.svg", + "/icons/icon-512.svg" +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) return caches.delete(key); + return null; + }) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, copy); + }); + return response; + }); + }) + ); +});