From e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 13:04:10 +0200 Subject: Initial commit: prognosis, the Go implementation An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests. --- .gitignore | 6 + LICENSE | 674 +++++++++++++++++++++ Makefile | 56 ++ README.md | 189 ++++++ cmd/prognosis/args_test.go | 49 ++ cmd/prognosis/main.go | 441 ++++++++++++++ cmd/prognosis/resolve_test.go | 171 ++++++ cmd/prognosis/resolver.go | 67 ++ cmd/prognosis/term_other.go | 6 + cmd/prognosis/term_unix.go | 28 + docs/superpowers/plans/2026-08-10-go-rewrite.md | 133 ++++ .../specs/2026-08-10-go-rewrite-design.md | 279 +++++++++ go.mod | 3 + internal/cache/cache.go | 251 ++++++++ internal/cache/cache_test.go | 282 +++++++++ internal/config/config.go | 360 +++++++++++ internal/config/config_test.go | 158 +++++ internal/i18n/i18n.go | 196 ++++++ internal/i18n/i18n_test.go | 88 +++ internal/imgw/imgw.go | 156 +++++ internal/imgw/imgw_test.go | 190 ++++++ internal/imgw/testdata/gugik_abroad.json | 1 + internal/imgw/testdata/warnings.json | 1 + internal/openmeteo/openmeteo.go | 385 ++++++++++++ internal/openmeteo/openmeteo_test.go | 364 +++++++++++ internal/openmeteo/testdata/forecast.json | 1 + internal/openmeteo/testdata/geocode_ambiguous.json | 1 + internal/openmeteo/testdata/pollen.json | 1 + internal/openmeteo/testdata/pollen_nulls.json | 1 + internal/render/ascii.go | 67 ++ internal/render/ascii_test.go | 55 ++ internal/render/chart.go | 205 +++++++ internal/render/color.go | 62 ++ internal/render/icons.go | 84 +++ internal/render/render.go | 283 +++++++++ internal/render/render_test.go | 287 +++++++++ internal/render/scale.go | 92 +++ internal/render/scale_test.go | 57 ++ internal/render/table.go | 240 ++++++++ internal/render/width.go | 110 ++++ internal/render/width_test.go | 112 ++++ 41 files changed, 6192 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/prognosis/args_test.go create mode 100644 cmd/prognosis/main.go create mode 100644 cmd/prognosis/resolve_test.go create mode 100644 cmd/prognosis/resolver.go create mode 100644 cmd/prognosis/term_other.go create mode 100644 cmd/prognosis/term_unix.go create mode 100644 docs/superpowers/plans/2026-08-10-go-rewrite.md create mode 100644 docs/superpowers/specs/2026-08-10-go-rewrite-design.md create mode 100644 go.mod create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/imgw/imgw.go create mode 100644 internal/imgw/imgw_test.go create mode 100644 internal/imgw/testdata/gugik_abroad.json create mode 100644 internal/imgw/testdata/warnings.json create mode 100644 internal/openmeteo/openmeteo.go create mode 100644 internal/openmeteo/openmeteo_test.go create mode 100644 internal/openmeteo/testdata/forecast.json create mode 100644 internal/openmeteo/testdata/geocode_ambiguous.json create mode 100644 internal/openmeteo/testdata/pollen.json create mode 100644 internal/openmeteo/testdata/pollen_nulls.json create mode 100644 internal/render/ascii.go create mode 100644 internal/render/ascii_test.go create mode 100644 internal/render/chart.go create mode 100644 internal/render/color.go create mode 100644 internal/render/icons.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/render/scale.go create mode 100644 internal/render/scale_test.go create mode 100644 internal/render/table.go create mode 100644 internal/render/width.go create mode 100644 internal/render/width_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a476214 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc + +# build artifacts +/prognosis +/dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..40c3ad2 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +.POSIX: +DESTDIR=$(HOME) +PREFIX=/.local +INSTALL_PATH=$(DESTDIR)$(PREFIX)/bin +BIN=prognosis +DIST=dist + +.PHONY: help build install uninstall test vet fmt ci cross clean + +help: ## show this help + @grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-16s\033[0m %s\n", $$1, $$2}' + +build: ## build the Go binary into ./$(BIN) + go build -trimpath -ldflags "-s -w" -o $(BIN) ./cmd/prognosis + +install: build ## build and install the Go binary + mkdir -p $(INSTALL_PATH) + # rm first: the target may be a symlink into this repo (see `link`), and cp + # follows symlinks -- it would write the binary over bin/prognosis itself. + rm -f $(INSTALL_PATH)/$(BIN) + cp $(BIN) $(INSTALL_PATH)/$(BIN) + chmod 755 $(INSTALL_PATH)/$(BIN) + @echo "Installed. Config: $$($(INSTALL_PATH)/$(BIN) -config)" + + + +uninstall: ## remove the installed binary + rm -f $(INSTALL_PATH)/$(BIN) + +test: ## run the tests + go test ./... + +vet: ## go vet + go vet ./... + +fmt: ## gofmt the tree + gofmt -w . + +ci: ## pre-push gate: gofmt clean, vet, tests, no third-party deps + @test -z "$$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; } + go vet ./... + go test ./... + @deps=$$(go list -deps ./... | grep -E '^[a-z0-9-]+\.[a-z]+/' | grep -v '^github.com/lukaszkasprzak/prognosis' || true); \ + test -z "$$deps" || { echo "third-party dependencies crept in:"; echo "$$deps"; exit 1; } + @echo "ci ok" + +cross: ## cross-compile into $(DIST)/ -- android/arm64 is the phone + mkdir -p $(DIST) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-linux-amd64 ./cmd/prognosis + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-linux-arm64 ./cmd/prognosis + CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(BIN)-android-arm64 ./cmd/prognosis + @ls -la $(DIST) + + +clean: ## remove build artifacts + rm -rf $(BIN) $(DIST) diff --git a/README.md b/README.md new file mode 100644 index 0000000..17ad747 --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# prognosis + +Hour-by-hour weather for the terminal, with official Polish warnings and pollen. + +Companion to [wego](https://github.com/schachmat/wego), which renders only four +dayparts per day. `prognosis` fills the gap wego cannot: an hourly table, a +temperature chart, IMGW warnings for your own powiat, and pollen counts with +qualitative bands. + +No API key. Written in Go with **no third-party modules** — `make ci` fails if +one creeps in. + +``` + ! Upał level 1 08-10 11:00 -> 08-10 20:00 (85%) + Prognozuje się upał. Temperatura maksymalna wyniesie od 30°C do 33°C. + +Krakow, PL Mon 10 Aug 05:15 up 20:01 down GMT+2 + day 12-30° dry sun 12h24m of 14h45m daylight + pollen grass 10.5 low mugwort 4.5 low ragweed 1.4 + + hr temp feels conditions + 13 28° part cloudy + 14 29° + 15 30° (28) mainly clear + ... + + 30°│ ▄▄▄▄▄██████████▄▄▄▄▄▄▄▄▄▄ + │▄▄▄▄▄██████████████████████████████▄▄▄▄▄ + 26°│████████████████████████████████████████ + │█████████████████████████████████████████████ + 22°│█████████████████████████████████████████████▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + 12 14 16 18 20 22 + 22-30° +``` + +## Install + + make build # build ./prognosis + make install # build and install into ~/.local/bin + make cross # dist/ binaries for linux-amd64, linux-arm64, android-arm64 + make ci # gofmt, vet, tests, and the no-dependencies check + +Needs `~/.local/bin` on `PATH`. For the phone, copy `dist/prognosis-android-arm64` +across — no interpreter, no shebang, nothing to install. + +## Configuration + +`~/.config/prognosis/config`, written with commented defaults on first run +(`prognosis -config` prints the path). One `KEY=VALUE` per line, `#` comments. +Flags override the file; the file overrides the built-in defaults. + +Columns are chosen and ordered freely: + + columns=hour,icon,temp,feels,conditions,wind,gusts,rain + +Available: `hour icon temp feels conditions mm rain wind gusts dir humidity dew +uv cloud pressure visibility`. Only the fields you select are requested from the +API. An unknown name is a startup error listing the valid ones, never a silently +blank column. + +`icons=` picks the glyph set: `nerd` (default), `emoji` or `none`. Nerd Font +glyphs are single-width and monochrome, so they follow the terminal palette; +emoji are colour glyphs from a fallback font and are not all one cell wide. + +`display_lang=` is `en` or `pl`, covering everything prognosis writes itself — +headers, condition names, labels, dates, pollen species and bands. **IMGW +publishes its warning text in Polish only**, so that text stays Polish in either +language: an invented English rendering of an official warning would be worse +than the original. + +## Usage + + prognosis # next 12 hours where you live + prognosis -l krakow # somewhere else + prognosis 50.0617,19.9373 # or by coordinates + prognosis -n 24 # next 24 hours + prognosis -d 3 # three days, 24h each (max 15) + prognosis --no-graph # table only + prognosis --no-color # plain text + +Location comes from `location=` in `~/.wegorc`, so wego and prognosis never +disagree about where you are. `-l` overrides it for one run. + +Quote a name that contains a comma or a space. `-l Wiry, PL` is two arguments +once the shell has finished with it, and prognosis refuses it rather than +quietly forecasting for whatever `Wiry,` alone resolves to. + +An ambiguous name is never guessed. prognosis lists what matched and fetches +nothing, so a request for one place can't silently return another: + + $ prognosis -l "Wiry, PL" + prognosis: "Wiry, PL" is ambiguous - nothing fetched. + 1 Wiry, PL Greater Poland 52.3205,16.8532 + 2 Wiry, PL Lower Silesia 50.8367,16.6467 + Re-run with -pick N, or give coordinates as the place. + +`-pick N` chooses one and remembers it, so you only do this once per name. It +also re-resolves rather than reading the cache, which is what lets it correct a +name that was cached wrongly. Exit status is 2 for both the ambiguity and an +out-of-range `-pick`. + +Output is pipe-safe: colour switches off when stdout is not a terminal, so +`prognosis > file` is clean UTF-8. Notes and failures go to stderr, so use +`2>&1` for an unattended job. + +## Data sources + +| Source | Used for | +|---|---| +| [Open-Meteo forecast](https://open-meteo.com/) | hourly temps, precipitation, codes, daily summary, sun times | +| [Open-Meteo air quality](https://open-meteo.com/en/docs/air-quality-api) | pollen per species | +| [Open-Meteo geocoding](https://open-meteo.com/en/docs/geocoding-api) | place name → coordinates | +| [GUGiK](https://services.gugik.gov.pl/uug/) | coordinates → TERYT powiat code (Poland) | +| [IMGW](https://danepubliczne.imgw.pl/) | official meteorological warnings | + +Geocoding and TERYT results are cached in `~/.cache/prognosis/cache.json`. +Forecast, warnings and pollen are fetched every run, so three HTTP calls. + +The cache is written one entry per line with its keys sorted, so it is readable +and hand-editable. It is also treated as disposable but never as worthless: an +entry that will not parse is skipped and the rest of the file is kept, and a file +that will not parse at all is moved to `cache.json.bad` rather than overwritten, +so a typo costs you the lookups but not what you wrote. + +## Warnings + +IMGW publishes every warning in Poland, each tagged with the TERYT codes of the +powiats it covers. GUGiK turns your coordinates into that code, so warnings are +filtered to your area rather than the whole country. + +Three states, kept deliberately distinct — **silence must never be mistaken for +all-clear**: + +- warnings printed — in force for your powiat +- nothing printed — checked, none in force +- `warnings: could not check IMGW` — the check itself failed +- `warnings: IMGW covers Poland only` — the location is abroad + +## Thresholds and why they are what they are + +Nothing here is a matter of taste, except where it says so. + +**Temperature colours** use IMGW's own warning criteria, so a red temperature +means the met office would issue a warning about it: + +| | criterion | colour | +|---|---|---| +| Silny mróz, stopień 1 | `Tmin ≤ -15°C` | bright blue | +| Upał, stopień 1 | `Tmax ≥ 30°C` | red | +| Upał, higher level | `Tmax > 35°C` | bright red | + +The divisions between (0, 10, 20) are round numbers, not thresholds from any +source; they only subdivide the range nobody warns about. + +**Pollen bands**, grains/m³, from Polish clinical sources: + +- **grass** — 20 = first nasal symptoms in ~25% of sufferers, 50 = symptoms in + all tested, 65 = intensified in over 75%, 120 = dyspnoea after 30 minutes + ([alergen.info.pl](http://www.alergen.info.pl/Alergeny/Pylek_trawy)) +- **birch** — 80 provokes symptoms in over 95% of allergics ([mp.pl](https://www.mp.pl/pacjent/alergie/lista/105140,jakie-czynniki-wplywaja-na-stezenie-alergenow-wziewnych)) +- **mugwort** — over 70 counts as high, intensified symptoms (mp.pl) + +Birch and mugwort have a single published anchor each, so they get a two-way +split rather than four bands — their "low" is weaker evidence than grass's. +Alder, olive and ragweed have no Polish threshold I could source and are shown +as bare numbers rather than banded on a guess. + +## Notes for anyone editing this + +- **Colour uses ANSI slots 0–15 only**, never 256-colour indices. It runs on a + terminal whose palette remaps the low slots to shades of green; a hardcoded + `38;5;196` would be the one non-green thing on screen. +- **Pad cells to width before colouring them.** Escape sequences carry no + visible width, so padding a coloured string misaligns the whole column. This + is invisible when piped (colour off) and obvious in a real terminal. +- **Open-Meteo hourly arrays start at 00:00 local.** Slicing from the front + reports this morning, not the hours ahead. See `openmeteo.WindowStart`. +- **The table does not shrink to fit.** Column widths are fixed; the default set + needs 34 columns. `TestTableMinimumWidthIsKnown` pins that figure. Use + `columns=` for a narrow terminal. +- **GUGiK's default search radius is 100 m**, which finds nothing in the + mountains or deep countryside — indistinguishable from being abroad. The + request asks for more; GUGiK clamps it to its own 5 km maximum. +- **The chart downsamples.** A week is 168 hourly points; columns cover several + hours on long spans and the header says `Nh/col` when they do. + +## Licence + +GNU General Public License v3. See `LICENSE`. diff --git a/cmd/prognosis/args_test.go b/cmd/prognosis/args_test.go new file mode 100644 index 0000000..4a85a2d --- /dev/null +++ b/cmd/prognosis/args_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "strings" + "testing" +) + +// `prognosis -l Wiry, PL` is the unquoted form of `-l "Wiry, PL"`. The shell +// splits it, so -l gets "Wiry," and "PL" arrives as a stray positional. Dropping +// it silently is what geocoded "Wiry," to Ukraine without anyone noticing. +func TestStrayPositionalBesideDashLIsAnError(t *testing.T) { + _, err := placeFromArgs("Wiry,", []string{"PL"}) + if err == nil { + t.Fatal("expected an error; silently ignoring the argument hides a quoting mistake") + } + if !strings.Contains(err.Error(), "PL") { + t.Errorf("error %q should name the argument that was ignored", err) + } +} + +func TestDashLIsThePlaceWhenNothingElseIsGiven(t *testing.T) { + got, err := placeFromArgs("Wiry, PL", nil) + if err != nil { + t.Fatal(err) + } + if got != "Wiry, PL" { + t.Errorf("got %q, want %q", got, "Wiry, PL") + } +} + +func TestPositionalsJoinIntoThePlace(t *testing.T) { + got, err := placeFromArgs("", []string{"Wiry,", "PL"}) + if err != nil { + t.Fatal(err) + } + if got != "Wiry, PL" { + t.Errorf("got %q, want %q: bare words are still accepted as the place", got, "Wiry, PL") + } +} + +func TestNoPlaceAnywhereIsNotAnError(t *testing.T) { + got, err := placeFromArgs("", nil) + if err != nil { + t.Fatalf("unexpected error %v: the config and ~/.wegorc are consulted next", err) + } + if got != "" { + t.Errorf("got %q, want empty", got) + } +} diff --git a/cmd/prognosis/main.go b/cmd/prognosis/main.go new file mode 100644 index 0000000..6e29c8e --- /dev/null +++ b/cmd/prognosis/main.go @@ -0,0 +1,441 @@ +// Command prognosis prints an hour-by-hour forecast, with official IMGW +// warnings for Polish locations. +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "os" + "strconv" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/cache" + "github.com/lukaszkasprzak/prognosis/internal/config" + "github.com/lukaszkasprzak/prognosis/internal/i18n" + "github.com/lukaszkasprzak/prognosis/internal/imgw" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" + "github.com/lukaszkasprzak/prognosis/internal/render" +) + +const wegorc = ".wegorc" + +func main() { os.Exit(run()) } + +func run() int { + // Android has no /etc/resolv.conf; without this every lookup fails. + configureResolver() + + var ( + location = flag.String("l", "", "place to query (default: config, then ~/.wegorc)") + hours = flag.Int("n", 0, "hours ahead to show") + days = flag.Int("d", 0, "days ahead to show, 24h each") + columns = flag.String("columns", "", "comma-separated columns to display") + icons = flag.String("icons", "", "icon set: nerd, emoji or none") + lang = flag.String("lang", "", "display language: en or pl") + noGraph = flag.Bool("no-graph", false, "table only, no chart") + weather = flag.Bool("weather", false, "forecast only: no sun times, summary, pollen or chart") + noWarn = flag.Bool("no-warnings", false, "omit IMGW warnings") + asciiOut = flag.Bool("ascii", false, "ASCII only, so an SMS stays in GSM-7") + noColor = flag.Bool("no-color", false, "plain output") + pick = flag.Int("pick", 0, "choose the Nth place when the name is ambiguous") + showCfg = flag.Bool("config", false, "print the config file path and exit") + ) + flag.Usage = usage + // Go's flag package stops at the first non-flag argument, so + // "prognosis 52.52,13.40 -n 3" would swallow the flags into the place name. + // Re-parse around each positional, which is what argparse does. + var positional []string + rest := os.Args[1:] + for { + if err := flag.CommandLine.Parse(rest); err != nil { + return 2 + } + if flag.NArg() == 0 { + break + } + positional = append(positional, flag.Arg(0)) + rest = flag.Args()[1:] + } + + cfgPath := config.Path() + if *showCfg { + fmt.Println(cfgPath) + return 0 + } + + cfg, err := config.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 2 + } + // Write the defaults out on first run, so the file documents itself. + if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) { + if err := config.WriteDefault(cfgPath, cfg); err == nil { + fmt.Fprintf(os.Stderr, "note: wrote default config to %s\n", cfgPath) + } + } + + // Flags override the file. The place is settled later, by placeFromArgs. + if *columns != "" { + cfg.Columns = splitList(*columns) + } + if *icons != "" { + cfg.Icons = *icons + } + if *lang != "" { + cfg.DisplayLang = *lang + } + if *noGraph { + cfg.Graph = false + } + if *weather { + // Meant for piping to someone else: the forecast and nothing else. + cfg.Minimal = true + cfg.Graph = false + } + if *noWarn { + cfg.Warnings = false + } + if *asciiOut { + cfg.ASCII = true + } + if cfg.ASCII { + // Both glyph sets are non-ASCII by definition. + cfg.Icons = "none" + } + if *noColor { + cfg.Color = "never" + } + switch { + case *days > 0 && *hours > 0: + fmt.Fprintln(os.Stderr, "prognosis: -n and -d cannot be combined") + return 2 + case *days > 0: + if *days > openmeteo.MaxForecastDays-1 { + fmt.Fprintf(os.Stderr, "prognosis: -d must be between 1 and %d\n", openmeteo.MaxForecastDays-1) + return 2 + } + cfg.Hours = *days * 24 + case *hours > 0: + cfg.Hours = *hours + case *days < 0 || *hours < 0: + fmt.Fprintln(os.Stderr, "prognosis: hours and days must be positive") + return 2 + } + + // -icons only chooses what the icon column draws. Without that column it + // changes nothing, which looks like the flag being ignored. + if *icons != "" && !cfg.Has("icon") { + fmt.Fprintf(os.Stderr, + "note: -icons has no effect: %q is not in columns (add it: -columns %s)\n", + "icon", strings.Join(append([]string{"hour", "icon"}, cfg.Columns[1:]...), ",")) + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 2 + } + + place, err := placeFromArgs(*location, positional) + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 2 + } + if place != "" { + cfg.Location = place + } + if cfg.Location == "" { + cfg.Location = locationFromWegorc() + } + if cfg.Location == "" { + fmt.Fprintf(os.Stderr, "prognosis: no location set in %s and none in ~/%s\n", cfgPath, wegorc) + return 2 + } + + store := cache.New(cache.DefaultPath()) + geo, err := resolve(store, cfg.Location, *pick) + if err != nil { + var amb *ambiguousError + if errors.As(err, &amb) { + fmt.Fprintf(os.Stderr, "prognosis: %s\n", ambiguousListing(amb)) + return 2 + } + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + var pe *pickError + if errors.As(err, &pe) { + return 2 + } + return 1 + } + + data, err := openmeteo.Forecast(geo.Lat, geo.Lon, cfg.Hours, cfg.Units, cfg.Fields()) + if err != nil { + fmt.Fprintf(os.Stderr, "prognosis: %v\n", err) + return 1 + } + cat := i18n.For(cfg.DisplayLang) + if len(data.Rows) < cfg.Hours { + fmt.Fprintf(os.Stderr, "note: %d %s %d\n", len(data.Rows), cat.Word("hours_available"), cfg.Hours) + } + + view := render.View{ + Label: geo.Label, TZ: data.TZ, Rows: data.Rows, + Sun: data.Sun, Daily: data.Daily, + } + if len(cfg.Pollen) > 0 { + // Pollen is a nicety: a failure here must not cost the forecast. + if peaks, err := openmeteo.Pollen(geo.Lat, geo.Lon, cfg.Hours, cfg.Pollen); err == nil { + view.Pollen = peaks + } + } + if cfg.Warnings { + view.Warnings, view.WarnNote, view.WarnFailed = warnings(store, geo, cat) + } + + colour := shouldColour(cfg.Color) + fmt.Println(render.Render(view, cfg, terminalWidth(), colour)) + return 0 +} + +// warnings resolves the powiat and fetches warnings for it. +// +// The three outcomes are deliberately distinct: a list, a note explaining why +// there can be none, or failed. Silence must never be read as all-clear. +func warnings(store *cache.Cache, geo cache.Geo, cat *i18n.Catalog) ([]imgw.Warning, string, bool) { + if geo.Country != "" && geo.Country != "PL" { + return nil, cat.Word("poland_only"), false + } + // A bare "lat,lon" carries no country, so ask GUGiK where the point is + // rather than inferring from a code we do not have. + key := fmt.Sprintf("%.4f,%.4f", geo.Lat, geo.Lon) + code, cached := store.Teryt(key) + if !cached { + var status imgw.Status + var err error + code, status, err = imgw.Powiat(geo.Lat, geo.Lon) + if err != nil || status == imgw.StatusError { + return nil, "", true + } + _ = store.PutTeryt(key, code) // "" records "not in Poland" + } + if code == "" { + return nil, cat.Word("poland_only"), false + } + live, err := imgw.Warnings(code) + if err != nil { + return nil, "", true + } + return live, "", false +} + +// placeFromArgs settles where the place name comes from: -l, or bare words, but +// never both. A leftover positional next to -l means the shell split an unquoted +// name, and accepting it silently discards half of what was typed. +func placeFromArgs(location string, positional []string) (string, error) { + if location != "" { + if len(positional) > 0 { + return "", fmt.Errorf("unexpected argument %q (did you mean -l %q?)", + strings.Join(positional, " "), + strings.Join(append([]string{location}, positional...), " ")) + } + return location, nil + } + // A positional argument is accepted as the place, as the Python version did. + return strings.Join(positional, " "), nil +} + +// geocode is a variable so tests can resolve a place without the network. +var geocode = openmeteo.Geocode + +// ambiguousError reports a name that matched several places with none chosen. +// Picking the first match silently is how a request for Wiry in Poland comes +// back as a forecast for Vyry in Ukraine, so resolve refuses and hands the +// candidates back for the caller to list. +type ambiguousError struct { + place string + candidates []openmeteo.Candidate +} + +func (e *ambiguousError) Error() string { + return fmt.Sprintf("%q is ambiguous - nothing fetched", e.place) +} + +// pickError reports a -pick outside the candidate list. It is a bad flag value, +// not a runtime failure, so it exits 2 like every other one. +type pickError struct { + place string + pick int + n int +} + +func (e *pickError) Error() string { + return fmt.Sprintf("-pick %d, but %q matched %d place(s)", e.pick, e.place, e.n) +} + +// ambiguousListing renders the candidates as a numbered list. It carries the +// region, because that is the only thing telling two places of one name apart, +// and the coordinates, because they are what a caller falls back to when it +// wants a place in a script rather than typing -pick every time. +func ambiguousListing(e *ambiguousError) string { + var b strings.Builder + fmt.Fprintf(&b, "%v.\n", e) + regions := make([]string, len(e.candidates)) + labelW, regionW := 0, 0 + for i, c := range e.candidates { + regions[i] = c.Admin1 + if regions[i] == "" { + regions[i] = "?" + } + // fmt pads by runes, so measure by runes or the diacritics misalign. + if n := len([]rune(c.Geo.Label)); n > labelW { + labelW = n + } + if n := len([]rune(regions[i])); n > regionW { + regionW = n + } + } + for i, c := range e.candidates { + fmt.Fprintf(&b, " %d %-*s %-*s %.4f,%.4f\n", + i+1, labelW, c.Geo.Label, regionW, regions[i], c.Geo.Lat, c.Geo.Lon) + } + fmt.Fprint(&b, "Re-run with -pick N, or give coordinates as the place.") + return b.String() +} + +// resolve turns a place name into coordinates. pick is 1-based and selects one +// of the geocoder's candidates; 0 means none was requested. +func resolve(store *cache.Cache, place string, pick int) (cache.Geo, error) { + if lat, lon, ok := parseCoords(place); ok { + // Coordinates carry no country code; callers must not assume one. + return cache.Geo{Lat: lat, Lon: lon, Label: place}, nil + } + // An explicit pick must re-resolve rather than read the cache: the entry + // sitting there is usually the wrong guess being corrected. + if pick == 0 { + if g, ok := store.Geo(place); ok { + return g, nil + } + } + cands, err := geocode(place) + if err != nil { + return cache.Geo{}, err + } + switch { + case pick != 0: + if pick < 1 || pick > len(cands) { + return cache.Geo{}, &pickError{place: place, pick: pick, n: len(cands)} + } + case len(cands) > 1: + return cache.Geo{}, &ambiguousError{place: place, candidates: cands} + default: + pick = 1 + } + g := cands[pick-1].Geo + _ = store.PutGeo(place, g) + return g, nil +} + +func parseCoords(s string) (float64, float64, bool) { + lat, lon, ok := strings.Cut(s, ",") + if !ok { + return 0, 0, false + } + a, err1 := strconv.ParseFloat(strings.TrimSpace(lat), 64) + b, err2 := strconv.ParseFloat(strings.TrimSpace(lon), 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return a, b, true +} + +// locationFromWegorc reads location= from wego's config, so the two tools never +// disagree about where you are. +func locationFromWegorc() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + f, err := os.Open(home + "/" + wegorc) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "location" { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + } + return "" +} + +func shouldColour(mode string) bool { + switch mode { + case "never": + return false + case "always": + return true + } + // Stdlib isatty: a terminal is a character device, a pipe or file is not. + fi, err := os.Stdout.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 && os.Getenv("TERM") != "dumb" +} + +func terminalWidth() int { + w := 80 + if env := os.Getenv("COLUMNS"); env != "" { + if n, err := strconv.Atoi(env); err == nil && n > 0 { + w = n + } + } else if n, ok := termCols(); ok { + w = n + } + w -= 4 + if w < 32 { + w = 32 + } + if w > 96 { + w = 96 + } + return w +} + +func splitList(v string) []string { + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func usage() { + fmt.Fprintf(os.Stderr, `prognosis - hour-by-hour forecast, with IMGW warnings for Poland + +usage: prognosis [flags] [place] + +flags: + -l PLACE place to query (default: config, then ~/.wegorc) + -pick N choose the Nth place when the name matches several + -n N hours ahead to show + -d N days ahead to show, 24h each (max %d) + -columns LIST comma-separated columns; valid: %s + -icons SET nerd, emoji or none + -lang LANG en or pl + -no-graph table only + -weather forecast only: no sun times, summary, pollen or chart + -no-warnings omit IMGW warnings + -ascii ASCII only, so an SMS stays in GSM-7 (160 chars, not 70) + -no-color plain output + -config print the config file path and exit +`, openmeteo.MaxForecastDays-1, strings.Join(config.ValidColumns(), ", ")) +} diff --git a/cmd/prognosis/resolve_test.go b/cmd/prognosis/resolve_test.go new file mode 100644 index 0000000..f7f1347 --- /dev/null +++ b/cmd/prognosis/resolve_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/lukaszkasprzak/prognosis/internal/cache" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +func tmpStore(t *testing.T) *cache.Cache { + t.Helper() + return cache.New(filepath.Join(t.TempDir(), "cache.json")) +} + +// stubGeocoder replaces the network for the duration of one test. +func stubGeocoder(t *testing.T, cands []openmeteo.Candidate) *int { + t.Helper() + calls := 0 + prev := geocode + geocode = func(string) ([]openmeteo.Candidate, error) { + calls++ + return cands, nil + } + t.Cleanup(func() { geocode = prev }) + return &calls +} + +func twoWirys() []openmeteo.Candidate { + return []openmeteo.Candidate{ + {Geo: cache.Geo{Lat: 52.3205, Lon: 16.8532, Label: "Wiry, PL", Country: "PL"}, Admin1: "Greater Poland"}, + {Geo: cache.Geo{Lat: 50.8367, Lon: 16.6467, Label: "Wiry, PL", Country: "PL"}, Admin1: "Lower Silesia"}, + } +} + +func TestResolveRefusesAnAmbiguousName(t *testing.T) { + stubGeocoder(t, twoWirys()) + _, err := resolve(tmpStore(t), "Wiry, PL", 0) + var amb *ambiguousError + if !errors.As(err, &amb) { + t.Fatalf("got err %v, want an ambiguousError: guessing is what sent the user to the wrong country", err) + } + if len(amb.candidates) != 2 { + t.Errorf("error carries %d candidates, want 2 so the user can choose", len(amb.candidates)) + } +} + +func TestResolveDoesNotCacheAnAmbiguousName(t *testing.T) { + stubGeocoder(t, twoWirys()) + store := tmpStore(t) + if _, err := resolve(store, "Wiry, PL", 0); err == nil { + t.Fatal("expected a refusal") + } + if g, ok := store.Geo("Wiry, PL"); ok { + t.Errorf("cached %+v for an ambiguous name; a wrong guess would stick forever", g) + } +} + +func TestResolvePickSelectsTheNthCandidate(t *testing.T) { + stubGeocoder(t, twoWirys()) + got, err := resolve(tmpStore(t), "Wiry, PL", 2) + if err != nil { + t.Fatal(err) + } + if got.Lat != 50.8367 { + t.Errorf("got lat %v, want 50.8367 (Lower Silesia, the second candidate)", got.Lat) + } +} + +func TestResolvePickRemembersTheChoice(t *testing.T) { + stubGeocoder(t, twoWirys()) + store := tmpStore(t) + if _, err := resolve(store, "Wiry, PL", 2); err != nil { + t.Fatal(err) + } + g, ok := store.Geo("Wiry, PL") + if !ok { + t.Fatal("a picked place was not cached, so the choice must be repeated every run") + } + if g.Lat != 50.8367 { + t.Errorf("cached lat %v, want the picked candidate's 50.8367", g.Lat) + } +} + +func TestResolvePickOutOfRangeIsAnError(t *testing.T) { + stubGeocoder(t, twoWirys()) + _, err := resolve(tmpStore(t), "Wiry, PL", 3) + if err == nil { + t.Fatal("expected an error: silently clamping would pick a place the user did not ask for") + } + // Every other bad flag value in this program exits 2; this must too, which + // means run() has to be able to tell it apart from a network failure. + var pe *pickError + if !errors.As(err, &pe) { + t.Errorf("got %T, want *pickError so run() can exit 2 rather than 1", err) + } +} + +// A name resolved wrongly before this change is still in the cache, and the +// cache is consulted first. Without this, --pick could never repair it. +func TestResolvePickBypassesAPoisonedCacheEntry(t *testing.T) { + calls := stubGeocoder(t, twoWirys()) + store := tmpStore(t) + poison := cache.Geo{Lat: 51.2417, Lon: 26.9411, Label: "Vyry, UA", Country: "UA"} + if err := store.PutGeo("Wiry, PL", poison); err != nil { + t.Fatal(err) + } + got, err := resolve(store, "Wiry, PL", 2) + if err != nil { + t.Fatal(err) + } + if *calls == 0 { + t.Error("--pick used the cache instead of re-resolving, so a bad entry can never be corrected") + } + if got.Country != "PL" { + t.Errorf("got %+v, want the picked Polish candidate", got) + } + if g, _ := store.Geo("Wiry, PL"); g.Country != "PL" { + t.Errorf("cache still holds %+v; the pick should overwrite it", g) + } +} + +func TestResolveCachesAnUnambiguousName(t *testing.T) { + only := []openmeteo.Candidate{ + {Geo: cache.Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}, Admin1: "Subcarpathia"}, + } + calls := stubGeocoder(t, only) + store := tmpStore(t) + for i := 0; i < 2; i++ { + got, err := resolve(store, "Krakow", 0) + if err != nil { + t.Fatalf("run %d: %v", i+1, err) + } + if got.Label != "Krakow, PL" { + t.Fatalf("run %d: got %+v", i+1, got) + } + } + if *calls != 1 { + t.Errorf("geocoded %d times, want 1: the second run should hit the cache", *calls) + } +} + +func TestResolveAcceptsBareCoordinates(t *testing.T) { + stubGeocoder(t, nil) + got, err := resolve(tmpStore(t), "50.8367,16.6467", 0) + if err != nil { + t.Fatal(err) + } + if got.Lat != 50.8367 || got.Lon != 16.6467 { + t.Errorf("got %+v, want the coordinates parsed as given", got) + } +} + +// The listing is the whole remedy: if it omits the region the user cannot tell +// the duplicates apart, and if it omits coordinates there is no way to reach a +// candidate that -pick is not being used for. +func TestAmbiguousListingIsActionable(t *testing.T) { + out := ambiguousListing(&ambiguousError{place: "Wiry, PL", candidates: twoWirys()}) + for _, want := range []string{ + "1", "2", + "Greater Poland", "Lower Silesia", + "52.3205", "50.8367", + "-pick", + } { + if !strings.Contains(out, want) { + t.Errorf("listing is missing %q; user cannot act on it:\n%s", want, out) + } + } +} diff --git a/cmd/prognosis/resolver.go b/cmd/prognosis/resolver.go new file mode 100644 index 0000000..095cc20 --- /dev/null +++ b/cmd/prognosis/resolver.go @@ -0,0 +1,67 @@ +package main + +import ( + "bufio" + "context" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// configureResolver teaches Go's resolver where the nameservers are on Android. +// +// Android has no /etc/resolv.conf. Go's pure-Go resolver falls back to +// localhost, so every lookup fails with "dial tcp [::1]:53: connection +// refused". Termux does ship one, at $PREFIX/etc/resolv.conf, which Go never +// consults. Reading it here uses whatever nameservers are actually configured +// rather than hardcoding any. +// +// A no-op everywhere /etc/resolv.conf exists, which is every other platform we +// build for. +func configureResolver() { + if _, err := os.Stat("/etc/resolv.conf"); err == nil { + return + } + prefix := os.Getenv("PREFIX") + if prefix == "" { + return + } + servers := nameservers(filepath.Join(prefix, "etc", "resolv.conf")) + if len(servers) == 0 { + return + } + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + var err error + for _, s := range servers { + var conn net.Conn + conn, err = d.DialContext(ctx, network, net.JoinHostPort(s, "53")) + if err == nil { + return conn, nil + } + } + return nil, err + }, + } +} + +func nameservers(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + var out []string + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) >= 2 && fields[0] == "nameserver" { + out = append(out, fields[1]) + } + } + return out +} diff --git a/cmd/prognosis/term_other.go b/cmd/prognosis/term_other.go new file mode 100644 index 0000000..d1263f0 --- /dev/null +++ b/cmd/prognosis/term_other.go @@ -0,0 +1,6 @@ +//go:build !(linux || darwin || freebsd || netbsd || openbsd) + +package main + +// termCols has no portable implementation here; COLUMNS or the default is used. +func termCols() (int, bool) { return 0, false } diff --git a/cmd/prognosis/term_unix.go b/cmd/prognosis/term_unix.go new file mode 100644 index 0000000..7cccf25 --- /dev/null +++ b/cmd/prognosis/term_unix.go @@ -0,0 +1,28 @@ +//go:build linux || darwin || freebsd || netbsd || openbsd + +package main + +import ( + "os" + "syscall" + "unsafe" +) + +// termCols asks the terminal for its width. +// +// This is an ioctl rather than golang.org/x/term because the spec keeps this +// binary free of third-party modules; it is a dozen lines and only needs to +// work on the platforms prognosis is built for. +func termCols() (int, bool) { + var ws struct{ Row, Col, Xpixel, Ypixel uint16 } + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + os.Stdout.Fd(), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(&ws)), + ) + if errno != 0 || ws.Col == 0 { + return 0, false + } + return int(ws.Col), true +} diff --git a/docs/superpowers/plans/2026-08-10-go-rewrite.md b/docs/superpowers/plans/2026-08-10-go-rewrite.md new file mode 100644 index 0000000..1286edc --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-go-rewrite.md @@ -0,0 +1,133 @@ +# prognosis Go rewrite — implementation plan + +> **For agentic workers:** implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Replace the Python `prognosis` with a configurable Go implementation +that keeps every behaviour in the spec's 16-item parity contract. + +**Architecture:** `cmd/prognosis` wires flags + config into three API clients +(`openmeteo`, `imgw`) behind a `cache`, then hands a view model to `render`. +Rendering is pure: it takes data and a config, returns lines, touches no network. + +**Tech stack:** Go 1.24, standard library only. No third-party modules. + +## Global constraints + +- Module `github.com/lukaszkasprzak/prognosis`, Go 1.24, **stdlib only**. +- Colour: ANSI slots 0–15 only (codes 30–37, 90–97) plus attributes 1/2/4. +- Every column pad goes through `render.Pad`, never `len()` or rune count. +- IMGW thresholds compared in °C regardless of display units. +- Licence header not required per-file; `LICENSE` is GPLv3 at the repo root. +- Python `bin/prognosis-py` stays in the repo, off PATH, until parity is signed off. + +--- + +### Task 1: Scaffold + config package + +**Files:** +- Create: `go.mod`, `internal/config/config.go`, `internal/config/config_test.go` +- Modify: `Makefile` (add Go targets) + +**Interfaces produced:** +- `config.Config` struct with fields `Location, Units, Icons, Color string; + Hours, GraphHeight int; Graph, Warnings bool; Columns, Pollen []string` +- `config.Default() Config` +- `config.Load(path string) (Config, error)` — KEY=VALUE, `#` comments +- `config.Validate() error` — unknown column/icon/colour names +- `config.WriteDefault(path string) error` — commented defaults +- `config.ValidColumns() []string` + +- [ ] Write table tests: parse, precedence, unknown column error naming offender, unknown icons value, malformed line, comment/blank handling +- [ ] Run `go test ./internal/config/` — expect failure +- [ ] Implement +- [ ] Run tests — expect pass + +### Task 2: cache package + +**Files:** `internal/cache/cache.go`, `internal/cache/cache_test.go` + +**Interfaces produced:** +- `cache.Get(section, key string) (string, bool)` +- `cache.Put(section, key, value string) error` +- `cache.GetGeo(place string) (Geo, bool)` / `cache.PutGeo(place string, g Geo)` +- `cache.Geo{Lat, Lon float64; Label, Country string}` + +- [ ] Tests: round-trip, missing key, corrupt file self-heals to empty, atomic write leaves no `.tmp`, concurrent writers leave valid JSON +- [ ] Implement with temp file + `os.Rename` + +### Task 3: render width + colour primitives + +**Files:** `internal/render/width.go`, `internal/render/color.go`, plus tests + +**Interfaces produced:** +- `render.DisplayWidth(s string) int` +- `render.Pad(s string, w int) string` / `render.PadLeft(s string, w int) string` +- `render.Styler(colour bool) func(code, text string) string` +- `render.Paint(cells []render.Cell, c func(string, string) string) string` +- `render.Cell{Style, Text string}` + +- [ ] Tests: VS16 pair counts 1, `⛅` counts 2, combining mark counts 0, ASCII counts len, Nerd glyph counts 1; `Pad` reaches the requested display width for all three icon sets; `Paint` groups runs +- [ ] Implement + +### Task 4: openmeteo client + +**Files:** `internal/openmeteo/openmeteo.go`, `_test.go`, `testdata/*.json` + +**Interfaces produced:** +- `openmeteo.Geocode(place string) (cache.Geo, []string, error)` — second value is alternatives for the ambiguity note +- `openmeteo.Forecast(lat, lon float64, hours int, units string, fields []string) (*openmeteo.Data, error)` +- `openmeteo.Pollen(lat, lon float64, hours int, species []string) (map[string]float64, error)` +- `openmeteo.Data{TZ string; Rows []Row; Sun map[string][2]string; Daily map[string]float64}` +- `openmeteo.Row{When time.Time; Vals map[string]float64; Code int}` + +- [ ] Record fixtures once from the live API into `testdata/` +- [ ] Tests against fixtures: window starts at the current hour not 00:00, requested fields only, units mapping, pollen forward window ≥12h +- [ ] Implement + +### Task 5: imgw + gugik + +**Files:** `internal/imgw/imgw.go`, `internal/imgw/gugik.go`, tests, fixtures + +**Interfaces produced:** +- `imgw.Powiat(lat, lon float64) (code string, status imgw.Status, err error)` +- `imgw.Status` = `StatusOK | StatusOutside | StatusError` +- `imgw.Warnings(powiat string) ([]imgw.Warning, error)` +- `imgw.Warning{Event, Level, From, To, Probability, Text string}` + +- [ ] Tests: TERYT truncated to 4 digits; 0-result response ⇒ `StatusOutside`; transport failure ⇒ `StatusError`; expired warning dropped; unparseable date kept; radius parameter present +- [ ] Implement + +### Task 6: render table + chart + +**Files:** `internal/render/table.go`, `internal/render/chart.go`, `internal/render/render.go`, tests + +**Interfaces produced:** +- `render.View{Label, TZ string; Rows []openmeteo.Row; Sun map[string][2]string; Daily map[string]float64; Pollen map[string]float64; Warnings []imgw.Warning; WarnNote string; WarnFailed bool}` +- `render.Render(v View, cfg config.Config, width int, colour bool) string` +- `render.TempStyle(c float64) string` +- `render.PollenBand(species string, v float64) string` + +- [ ] Tests: temp band edges −15/30/35 exactly; rounded-value colouring (−14.6 ⇒ same as −15); dry window hides mm/rain; conditions repeat suppressed and reset per day; chart widen/downsample and `Nh/col`; axis labels skipped not truncated; all four warning states +- [ ] Implement + +### Task 7: cmd wiring + +**Files:** `cmd/prognosis/main.go` + +- [ ] Flags mirroring the spec's mapping table; flags > config > defaults +- [ ] Exit codes 0/1/2; notes to stderr +- [ ] Write default config on first run + +### Task 8: parity harness + +**Files:** `scripts/parity.sh`, Makefile target `parity` + +- [ ] Uses a bash/zsh **array** for arguments, never an unquoted string (zsh does not word-split; a string silently degrades every case into a usage error that compares equal) +- [ ] Runs both binaries back to back, compares layout: line count, column start positions, sections present, stderr and exit code exactly; tolerates numeric drift +- [ ] Covers the spec's matrix including `COLUMNS=53` and all three icon sets + +### Task 9: build, cross-compile, install + +- [ ] `make ci` clean: fmt, vet, test +- [ ] `make cross` produces `linux/amd64`, `linux/arm64`, `android/arm64` +- [ ] Verify the android/arm64 binary runs on the phone diff --git a/docs/superpowers/specs/2026-08-10-go-rewrite-design.md b/docs/superpowers/specs/2026-08-10-go-rewrite-design.md new file mode 100644 index 0000000..f592412 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-go-rewrite-design.md @@ -0,0 +1,279 @@ +# prognosis in Go — design + +**Date:** 2026-08-10 +**Status:** implemented and in service on both machines. The Python +implementation was retired on 2026-08-10 once the Go packages had offline tests +of their own; a copy is at ~/.local/share/backups/2026-08-10-prognosis-py/. +**Supersedes:** the Python implementation, retired 2026-08-10. + +## Why + +Two reasons, neither of them "Go is nicer". + +1. **Deployment.** The phone currently runs a copied Python file and depends on + Termux's `python3` plus `termux-exec` rewriting a `#!/usr/bin/env` shebang + that cannot resolve on Android, which has no `/usr/bin`. A static + `android/arm64` binary removes that whole chain. +2. **Configurability.** The display is hardcoded. Choosing columns, toggling the + chart and picking pollen species wants a config file, and the Python version + has no structure for it. + +A rewrite of something that already works carries one real risk: the dozen small +behaviours that took a day to find are easy to drop silently. The parity harness +below exists to make that impossible, and is built early rather than last. + +## Layout + +Follows `bread-calc`, the house convention for a Go tool. + +``` +prognosis/ +├── cmd/prognosis/main.go flag parsing, wiring, exit codes +├── internal/ +│ ├── config/ KEY=VALUE parser, defaults, column set +│ ├── openmeteo/ forecast, air-quality, geocoding clients +│ ├── imgw/ warnings + GUGiK TERYT lookup +│ ├── cache/ geo/teryt cache, atomic write +│ └── render/ table, chart, colour +├── testdata/ recorded JSON fixtures, no network in tests +├── bin/prognosis-py the Python implementation, until parity +├── go.mod github.com/lukaszkasprzak/prognosis +├── Makefile build install test vet fmt ci cross clean +├── LICENSE GPLv3 +└── README.md +``` + +## Config + +`~/.config/prognosis/config`, `KEY=VALUE`, `#` comments, parsed by our own code. +No dependency. Written with commented defaults on first run, so the file +documents itself — the same trick wego's `ingo` uses, without the library. + +``` +# ~/.config/prognosis/config + +location=Krakow # falls back to location= in ~/.wegorc when unset +hours=12 # default span; -n / -d override +units=metric # metric | imperial | si + +columns=hour,icon,temp,feels,conditions,rain + +icons=nerd # nerd | emoji | none +graph=true +graph_height=5 +warnings=true +pollen=all # species to show; a list, "all" or "none" +color=auto # auto | always | never +display_lang=en # en | pl +``` + +`display_lang` covers everything prognosis writes itself: column headers, +condition names, section labels, weekday and month names, pollen species and +bands. **IMGW publishes its warning text in Polish only**, so that text stays +Polish in either language; translating an official warning would mean inventing +its wording. + +Precedence: **flags > config file > built-in defaults**. Location additionally +falls back to `~/.wegorc` so the two weather tools never disagree about where +you are. + +`units` is passed to Open-Meteo as `temperature_unit` / `wind_speed_unit` / +`precipitation_unit` rather than converted locally, so rounding matches the +provider: `metric` = °C, km/h, mm; `imperial` = °F, mph, inch; `si` = °C, m/s, +mm. The IMGW temperature thresholds are defined in °C and are compared against +the Celsius value regardless of display units — a warning threshold does not +move because you changed how numbers are printed. + +Flag/config mapping, so both spellings exist for every knob: + +| flag | config key | +|---|---| +| `-l`, `--location` | `location` | +| `-n`, `--hours` / `-d`, `--days` | `hours` (days × 24) | +| `--no-graph` | `graph=false` | +| `--no-color` | `color=never` | +| `--columns` | `columns` | +| `--icons` | `icons` | +| `--lang` | `display_lang` | + +`color=auto` means colour when stdout is a terminal and `TERM` is not `dumb`, +which is the current behaviour; `always` forces it on for piping into a pager. + +### Columns + +An ordered, named set. Unknown names are a startup error naming the offender and +listing what is valid — never a silently blank column. + +| name | source field | notes | +|---|---|---| +| `hour` | derived | current hour emphasised | +| `icon` | `weather_code` | glyph for the conditions, see below | +| `temp` | `temperature_2m` | coloured by IMGW bands | +| `feels` | `apparent_temperature` | shown only when it differs by ≥1° | +| `conditions` | `weather_code` | shown only when it changes | +| `mm` | `precipitation` | | +| `rain` | `precipitation_probability` | | +| `wind` | `wind_speed_10m` | | +| `gusts` | `wind_gusts_10m` | | +| `dir` | `wind_direction_10m` | rendered as an arrow | +| `humidity` | `relative_humidity_2m` | | +| `dew` | `dew_point_2m` | | +| `uv` | `uv_index` | | +| `cloud` | `cloud_cover` | | +| `pressure` | `pressure_msl` | | +| `visibility` | `visibility` | metres → km | + +Only the fields actually selected are requested from the API, so a narrow column +set costs a smaller response. + +### Icons + +The `icon` column renders the weather code as a glyph. `icons=` picks the set: + +- `emoji` — ☀ ⛅ ☁ 🌧 ⛈ 🌨 🌫. Colour glyphs, drawn by a fallback font. +- `nerd` — Nerd Font weather glyphs. Single-width, monochrome, so they take the + terminal's foreground colour like any other text. +- `none` — the column renders empty (kept so `columns=` need not change). + +**The right default differs per machine, which is why this is config.** + +The first version of this spec defaulted to `nerd` on the grounds that both +machines have Nerd Fonts installed. Testing disproved that: *installed* is not +*reachable*. + +- **t480 (st):** the primary font is Terminus, which has no private-use glyphs, + so icons go through Xft's fallback path — which + `~/.config/fontconfig/conf.d/60-st-terminus-fallback.conf` deliberately steers + to DejaVu Sans Mono, to stop fallback glyphs being sheared to the Terminus + cell. DejaVu Sans Mono contains U+2601 (emoji cloud) but neither U+26C5 (emoji + sun-behind-cloud) nor the Nerd range at U+E3xx. So on st, **`nerd` draws + nothing and `emoji` is only partially covered**; `icons=none` is the honest + setting there unless the fallback is extended. +- **pixel (Termux):** the *primary* font is MesloLGS NF, so the Nerd range is + covered directly with no fallback involved. `nerd` is right there, and it is + also the only set that respects the deliberately monochrome green palette — a + colour emoji would be the one non-green thing on screen. + +The default stays `nerd`: it is correct on the machine where a glyph column +earns its place, and it fails blank rather than wrong. Per-machine config is the +mechanism for the difference. + +Making `nerd` work on st would mean adding the Nerd range to the fallback — an +additive fontconfig file beside the existing one, or st's `font2[]` — which +touches a carefully tuned working setup and is deliberately out of scope here. + +**Width must be measured, not counted.** Weather emoji do not share a width: +`⛅` is East-Asian Wide (2 cells), `⛈` is Ambiguous, `☀ ☁ ❄` are Neutral, and +`☀️` is two runes because of variation selector U+FE0F, which pushes most +terminals to double-width. Neither `len()` nor `utf8.RuneCountInString` gives the +display width. The renderer needs a `displayWidth(string) int` that accounts for +combining marks, variation selectors and East-Asian width, and **every column +pad must go through it**. This is the emoji-shaped version of the +pad-before-colour rule: get it wrong and the whole table shears, but only in a +real terminal, never when piped. + +The existing rule that rain columns vanish on a dry window becomes conditional +on them being selected at all: if `mm`/`rain` are in `columns` and the window is +dry (no precipitation and no hour at or above 20% probability), they are hidden, +and the day line says `dry`. + +## Parity contract + +Every item below is behaviour the Python version has, each of which cost +something to discover. The Go version must reproduce all of them, and the parity +harness must cover each one. + +1. **Four warning states, kept distinct.** Warnings shown; nothing shown + (checked, none in force); `could not check IMGW` (the check failed); + `IMGW covers Poland only` (location abroad). Silence must never be mistaken + for all-clear. +2. **Powiat filtering.** GUGiK reverse-geocode → 6-digit TERYT → first 4 digits → + match against each warning's `teryt` array. Warnings whose `obowiazuje_do` has + passed are dropped; an unparseable date is kept rather than dropped. +3. **GUGiK radius.** Request a wide radius (the service clamps to its own 5 km + maximum). The 100 m default finds nothing in the mountains, which is + indistinguishable from being abroad and would suppress real warnings. +4. **Temperature colours from IMGW criteria**, with exact edges: + `Tmin ≤ -15` bright blue, `Tmax ≥ 30` red, `Tmax > 35` bright red. Note `≤` + and `>` — both edges were wrong in a first attempt. Intermediate splits at + 0/10/20 are round numbers and are documented as such. +5. **Colour computed from the rounded value**, so a reading of −14.6 that prints + as `-15°` gets the same colour as a true −15°. +6. **ANSI slots 0–15 only.** No 256-colour indices: the phone's palette remaps + the low slots to shades of green and a hardcoded index would be the one + non-green thing on screen. +7. **Pad cells to width before colouring.** Escape sequences have no visible + width; padding a coloured string misaligns the column. Invisible when piped, + obvious in a terminal. +8. **Colour runs are grouped**, one escape per colour change rather than per + character. +9. **Display width is measured, not counted** -- East-Asian width, combining + marks and variation selectors -- and every pad goes through it. Emoji are not + all one cell wide; a rune count shears the table in a real terminal while + looking correct when piped. +10. **Pollen bands** with per-species evidence strength: grass four bands + (20/50/65/120), birch and mugwort a two-way split on a single anchor (80, 70), + alder/olive/ragweed unbanded. Grass is never hidden even at zero; other + species are hidden when absent. +11. **Pollen window looks forward** from the current hour, at least 12 hours, on + arrays that begin at 00:00 local. +12. **Chart** over `graph_height` rows using half-block cells; columns widen to + fill narrow spans and downsample on long ones, with `Nh/col` shown when they + do; axis labels are skipped rather than truncated when they would overrun. +13. **Terminal width** read at runtime, honouring `COLUMNS`; header folds sun + times onto one line only when it fits. +14. **Atomic cache writes** (temp file + rename), so concurrent runs cannot leave + truncated JSON. +15. **Pipe-safe**: colour off when stdout is not a terminal; notes and failures + on stderr; `note: N hours available, not M` when the API returns fewer hours + than requested. +16. **Exit codes**: 0 success, 1 fetch failure, 2 usage error. + +## Testing + +Unit tests, table-driven, no network: + +- config parsing: precedence, unknown keys, unknown column names, malformed lines +- column selection: order preserved, dry-window hiding, only-selected-fields requested +- thresholds: temperature band edges (−15, 30, 35 exactly), pollen bands per species +- chart: scaling, widening, downsampling, axis label placement and skipping +- render: pad-before-colour width invariants, colour-run grouping, ANSI slots used +- width: display width of emoji (wide, ambiguous, VS16 pairs), Nerd Font glyphs + and plain ASCII, and that every column stays aligned across all three icon sets + +HTTP clients are tested against recorded JSON in `testdata/`, captured from the +live APIs once. This also documents the response shapes. + +## Parity harness + +`make parity` runs both implementations over a fixed argument matrix and diffs +stdout, stderr and exit code: + +``` +--no-color / -n 1 / -n 30 / -d 1 / -d 2 / -d 5 / --no-graph +-l Bergen -n 6 (wet window: rain columns appear) +-l Ushuaia -n 4 (cold bands, negative-zero formatting) +52.52,13.40 -n 3 (abroad by coordinates) +49.23,19.98 -n 3 (remote Polish point: GUGiK radius) +-d 16 / -n 0 / -d 0 / -n 5 -d 2 (usage errors and exit codes) +COLUMNS=53 (narrow rendering, header fold, wrap) +--icons=emoji / --icons=nerd / --icons=none (column alignment per set) +``` + +Live data changes between runs, so both binaries are invoked back to back and +the harness compares structure: line count, column positions, which sections are +present, and the stderr/exit code exactly. Numeric drift between two calls +seconds apart is tolerated; layout differences are not. + +**A warning learned the hard way:** the shell here is zsh, which does **not** +word-split unquoted variables. A harness looping over argument strings must use +`${=args}` or an array, or every case silently degrades into an argparse error +and the comparison passes while testing nothing. + +## Out of scope + +- Air quality (PM2.5/PM10/AQI) — the endpoint is already called for pollen, so + it is cheap to add later, but it is not in this rewrite. +- Cron/mail integration. Output is already pipe-safe; no code needed. +- Retiring the Python implementation. That happens after parity, as a separate + decision. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..709b4d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lukaszkasprzak/prognosis + +go 1.24.4 diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..892df4a --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,251 @@ +// Package cache stores the lookups that never change: a place name's +// coordinates, and a coordinate's TERYT powiat code. +// +// Forecasts are never cached -- they would be stale immediately. +package cache + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// Geo is a resolved location. +type Geo struct { + Lat float64 + Lon float64 + Label string + Country string +} + +// Geo is stored as a 4-element array, not an object, because the Python +// implementation shares this file and reads [lat, lon, label, country]. The two +// coexist until the Go version reaches parity, and a cache one of them cannot +// read makes the other crash on its own data. +func (g Geo) MarshalJSON() ([]byte, error) { return g.jsonLine() } + +// jsonLine renders the stored array with a space after each comma, which is how +// it is written to disk. encoding/json compacts whatever a Marshaler returns, so +// the file writer calls this directly; going through json.Marshal would strip +// the spaces again. Element order is the contract described above, kept here so +// it is stated once. +func (g Geo) jsonLine() ([]byte, error) { + parts := make([][]byte, 0, 4) + for _, v := range []any{g.Lat, g.Lon, g.Label, g.Country} { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + parts = append(parts, b) + } + return append(append([]byte{'['}, bytes.Join(parts, []byte(", "))...), ']'), nil +} + +func (g *Geo) UnmarshalJSON(b []byte) error { + var raw []any + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if len(raw) < 3 { + return fmt.Errorf("geo entry has %d fields, want at least 3", len(raw)) + } + lat, ok1 := raw[0].(float64) + lon, ok2 := raw[1].(float64) + if !ok1 || !ok2 { + return fmt.Errorf("geo entry has non-numeric coordinates") + } + label, _ := raw[2].(string) + country := "" + if len(raw) > 3 { + country, _ = raw[3].(string) + } + *g = Geo{Lat: lat, Lon: lon, Label: label, Country: country} + return nil +} + +type store struct { + Geo map[string]Geo `json:"geo"` + Teryt map[string]string `json:"teryt"` +} + +// Cache is a JSON file holding both sections. +type Cache struct{ path string } + +// New returns a cache backed by path. The file need not exist. +func New(path string) *Cache { return &Cache{path: path} } + +// DefaultPath is ~/.cache/prognosis/cache.json. +func DefaultPath() string { + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "prognosis", "cache.json") + } + return filepath.Join(os.Getenv("HOME"), ".cache", "prognosis", "cache.json") +} + +// load never fails the run: a cache it cannot read is treated as empty, because +// losing a cache costs one extra request and failing the run costs the forecast. +// +// It salvages per entry rather than per file. Decoding the sections whole meant +// one unreadable entry made the entire cache look empty, and the save that +// followed then wrote that emptiness over every entry that was still good. +// +// The second result reports whether the file was usable. It is false only when +// the document itself will not parse, which tells the writers to move it aside +// before replacing it: the cache is disposable, but a file someone hand-edited +// is the only copy of what they typed. +func (c *Cache) load() (store, bool) { + s := store{Geo: map[string]Geo{}, Teryt: map[string]string{}} + data, err := os.ReadFile(c.path) + if err != nil { + return s, true // absent is not corrupt; a fresh cache may be written + } + if len(bytes.TrimSpace(data)) == 0 { + return s, true // an empty file is simply no cache yet + } + var raw struct { + Geo map[string]json.RawMessage `json:"geo"` + Teryt map[string]json.RawMessage `json:"teryt"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return s, false + } + for k, v := range raw.Geo { + var g Geo + if err := json.Unmarshal(v, &g); err == nil { + s.Geo[k] = g + } + } + for k, v := range raw.Teryt { + var code string + if err := json.Unmarshal(v, &code); err == nil { + s.Teryt[k] = code + } + } + return s, true +} + +// encode writes the store one entry per line, keys sorted so the file is stable +// between runs. It is small, hand-edited, and read in a terminal, none of which +// a single long line serves. +func encode(s store) ([]byte, error) { + geo := make(map[string]json.RawMessage, len(s.Geo)) + for k, g := range s.Geo { + v, err := g.jsonLine() + if err != nil { + return nil, err + } + geo[k] = v + } + teryt := make(map[string]json.RawMessage, len(s.Teryt)) + for k, code := range s.Teryt { + v, err := json.Marshal(code) + if err != nil { + return nil, err + } + teryt[k] = v + } + var b bytes.Buffer + b.WriteString("{\n") + writeSection(&b, "geo", geo) + b.WriteString(",\n") + writeSection(&b, "teryt", teryt) + b.WriteString("\n}\n") + return b.Bytes(), nil +} + +func writeSection(b *bytes.Buffer, name string, entries map[string]json.RawMessage) { + if len(entries) == 0 { + fmt.Fprintf(b, " %q: {}", name) + return + } + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Fprintf(b, " %q: {\n", name) + for i, k := range keys { + key, err := json.Marshal(k) // a place name may contain anything + if err != nil { + continue + } + fmt.Fprintf(b, " %s: %s", key, entries[k]) + if i < len(keys)-1 { + b.WriteByte(',') + } + b.WriteByte('\n') + } + b.WriteString(" }") +} + +// save writes atomically: a temporary file in the same directory, then a +// rename. Two runs at once would otherwise interleave and leave truncated JSON +// that the next run silently reads as an empty cache. +func (c *Cache) save(s store) error { + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + tmp := fmt.Sprintf("%s.%d.tmp", c.path, os.Getpid()) + data, err := encode(s) + if err != nil { + return err + } + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, c.path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// quarantine moves a cache that will not parse to .bad, so replacing it +// costs nothing that cannot be recovered. Losing the cache is cheap -- one extra +// request -- but losing a hand-edit is not, and the two used to be the same act. +func (c *Cache) quarantine() error { + return os.Rename(c.path, c.path+".bad") +} + +// Geo returns a cached location. +func (c *Cache) Geo(place string) (Geo, bool) { + s, _ := c.load() + g, ok := s.Geo[place] + return g, ok +} + +// PutGeo records a location. +func (c *Cache) PutGeo(place string, g Geo) error { + s, ok := c.load() + if !ok { + if err := c.quarantine(); err != nil { + return err + } + } + s.Geo[place] = g + return c.save(s) +} + +// Teryt returns a cached powiat code. The empty string is a real answer meaning +// "GUGiK knows this point is not in Poland"; the boolean distinguishes it from +// never having asked. +func (c *Cache) Teryt(key string) (string, bool) { + s, _ := c.load() + code, ok := s.Teryt[key] + return code, ok +} + +// PutTeryt records a powiat code, or "" for a point outside Poland. +func (c *Cache) PutTeryt(key, code string) error { + s, ok := c.load() + if !ok { + if err := c.quarantine(); err != nil { + return err + } + } + s.Teryt[key] = code + return c.save(s) +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..3283db2 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,282 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" +) + +func tmpCache(t *testing.T) *Cache { + t.Helper() + return New(filepath.Join(t.TempDir(), "sub", "cache.json")) +} + +func TestGeoRoundTrip(t *testing.T) { + c := tmpCache(t) + if _, ok := c.Geo("Krakow"); ok { + t.Fatal("empty cache reported a hit") + } + want := Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"} + if err := c.PutGeo("Krakow", want); err != nil { + t.Fatal(err) + } + got, ok := c.Geo("Krakow") + if !ok || got != want { + t.Fatalf("got %+v (%v), want %+v", got, ok, want) + } +} + +// "" is a real answer -- not in Poland -- and must be distinguishable from +// never having asked, or every foreign location re-queries GUGiK forever. +func TestTerytEmptyStringIsARealAnswer(t *testing.T) { + c := tmpCache(t) + if _, ok := c.Teryt("52.5200,13.4000"); ok { + t.Fatal("empty cache reported a hit") + } + if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { + t.Fatal(err) + } + code, ok := c.Teryt("52.5200,13.4000") + if !ok { + t.Fatal("a cached empty code must report as present") + } + if code != "" { + t.Fatalf("code = %q, want empty", code) + } +} + +func TestCorruptFileIsTreatedAsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + c := New(path) + if _, ok := c.Geo("anything"); ok { + t.Fatal("corrupt cache must read as empty, not error") + } + if err := c.PutGeo("x", Geo{Lat: 1}); err != nil { + t.Fatalf("must be able to overwrite a corrupt cache: %v", err) + } +} + +func TestSaveLeavesNoTempFiles(t *testing.T) { + dir := t.TempDir() + c := New(filepath.Join(dir, "cache.json")) + if err := c.PutGeo("a", Geo{Lat: 1}); err != nil { + t.Fatal(err) + } + entries, _ := filepath.Glob(filepath.Join(dir, "*.tmp")) + if len(entries) != 0 { + t.Fatalf("temp files left behind: %v", entries) + } +} + +// The whole point of the atomic write: concurrent writers must never leave a +// file that fails to parse. +func TestConcurrentWritesKeepValidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + New(path).PutGeo("place", Geo{Lat: float64(i)}) + }(i) + } + wg.Wait() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var s store + if err := json.Unmarshal(data, &s); err != nil { + t.Fatalf("cache is not valid JSON after concurrent writes: %v\n%s", err, data) + } +} + +// The Python implementation shares this file and stores geo entries as +// [lat, lon, label, country]. If Go writes an object instead, Python crashes on +// its own cache -- which is exactly what happened once. +func TestGeoIsStoredAsAnArrayForPythonInterop(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + c := New(path) + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + var probe struct { + Geo map[string][]any `json:"geo"` + } + if err := json.Unmarshal(data, &probe); err != nil { + t.Fatalf("geo must decode as arrays: %v\n%s", err, data) + } + entry := probe.Geo["Krakow"] + if len(entry) != 4 { + t.Fatalf("geo entry = %v, want 4 elements", entry) + } + if entry[2] != "Krakow, PL" { + t.Errorf("third element must be the label, got %v", entry[2]) + } +} + +// A cache written in the Python shape must load here unchanged. +func TestReadsPythonWrittenCache(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + body := `{"geo":{"Krakow":[50.06170,19.93730,"Krakow, PL","PL"]},"teryt":{"50.0617,19.9373":"1815"}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + c := New(path) + g, ok := c.Geo("Krakow") + if !ok || g.Label != "Krakow, PL" || g.Country != "PL" { + t.Fatalf("got %+v (%v)", g, ok) + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Fatalf("teryt = %q (%v)", code, ok) + } +} + +// writeCache puts raw bytes where the cache expects its file. +func writeCache(t *testing.T, c *Cache, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// One unreadable entry used to cost the whole file: load treated the parse +// error as "empty cache", and the next save wrote that emptiness over +// everything that was still fine. +func TestOneMalformedEntryDoesNotDestroyTheOthers(t *testing.T) { + c := tmpCache(t) + writeCache(t, c, `{"geo":{"Gdansk":[54.35227,18.64912,"Gdansk, PL","PL"],"Broken":[1]},"teryt":{"50.0617,19.9373":"1815"}}`) + + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + if _, ok := c.Geo("Gdansk"); !ok { + t.Error("a good entry was destroyed by an unrelated malformed one") + } + if _, ok := c.Geo("Krakow"); !ok { + t.Error("the new entry was not stored") + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Errorf("teryt section lost too: got %q, %v", code, ok) + } + if _, ok := c.Geo("Broken"); ok { + t.Error("the malformed entry should be dropped, not resurrected") + } +} + +// A hand-edit that breaks the whole document must not cost the file. The cache +// is disposable, but whatever was typed into it is not, so it moves aside +// rather than being overwritten -- and the run still gets a working cache. +func TestAnUnparseableFileIsMovedAsideNotDestroyed(t *testing.T) { + c := tmpCache(t) + const broken = `{"geo":{"Gdansk":[54.35227,` + writeCache(t, c, broken) + + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373}); err != nil { + t.Fatalf("the tool must keep working: %v", err) + } + kept, err := os.ReadFile(c.path + ".bad") + if err != nil { + t.Fatalf("the unparseable file was not preserved: %v", err) + } + if string(kept) != broken { + t.Errorf("preserved copy differs:\n got %s\nwant %s", kept, broken) + } + if _, ok := c.Geo("Krakow"); !ok { + t.Error("the fresh cache did not take the new entry") + } +} + +func TestPutTerytAlsoMovesAnUnparseableFileAside(t *testing.T) { + c := tmpCache(t) + const broken = `{oops` + writeCache(t, c, broken) + if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil { + t.Fatalf("the tool must keep working: %v", err) + } + kept, err := os.ReadFile(c.path + ".bad") + if err != nil || string(kept) != broken { + t.Errorf("unparseable file not preserved: %q, %v", kept, err) + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Errorf("fresh cache did not take the entry: %q, %v", code, ok) + } +} + +// The file is read by a human at least as often as by the program. +func TestSaveWritesOneEntryPerLine(t *testing.T) { + c := tmpCache(t) + if err := c.PutGeo("Krakow", Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + if err := c.PutGeo("Chiang Mai", Geo{Lat: 18.79038, Lon: 98.98468, Label: "Chiang Mai, TH", Country: "TH"}); err != nil { + t.Fatal(err) + } + if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(c.path) + if err != nil { + t.Fatal(err) + } + want := `{ + "geo": { + "Chiang Mai": [18.79038, 98.98468, "Chiang Mai, TH", "TH"], + "Krakow": [50.06170, 19.93730, "Krakow, PL", "PL"] + }, + "teryt": { + "50.0617,19.9373": "1815" + } +} +` + if string(body) != want { + t.Errorf("got:\n%s\nwant:\n%s", body, want) + } +} + +func TestSavedFileIsStillValidJSONForTheOtherImplementation(t *testing.T) { + c := tmpCache(t) + want := Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"} + if err := c.PutGeo("Krakow", want); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(c.path) + if err != nil { + t.Fatal(err) + } + var got struct { + Geo map[string][]any `json:"geo"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("a stock JSON parser could not read it: %v", err) + } + row := got.Geo["Krakow"] + if len(row) != 4 { + t.Fatalf("got %d fields, want the 4-element shape Python reads: %v", len(row), row) + } + if row[2] != "Krakow, PL" { + t.Errorf("label field is %v, want the third element", row[2]) + } +} + +func TestEmptyCacheSavesReadableJSON(t *testing.T) { + c := tmpCache(t) + if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(c.path) + if !json.Valid(body) { + t.Fatalf("invalid JSON with an empty geo section:\n%s", body) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..51d8c27 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,360 @@ +// Package config reads prognosis' KEY=VALUE configuration file. +// +// The format is deliberately the same shape as wego's ~/.wegorc: one KEY=VALUE +// per line, '#' starts a comment, values are never quoted. Parsing it here +// rather than pulling in a config library keeps the binary dependency-free. +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// Columns available for the hourly table, in the order they are documented. +// The value is the Open-Meteo hourly field the column needs, or "" when the +// column is derived from data already fetched. +var columnFields = map[string]string{ + "hour": "", + "icon": "weather_code", + "temp": "temperature_2m", + "feels": "apparent_temperature", + "conditions": "weather_code", + "mm": "precipitation", + "rain": "precipitation_probability", + "wind": "wind_speed_10m", + "gusts": "wind_gusts_10m", + "dir": "wind_direction_10m", + "humidity": "relative_humidity_2m", + "dew": "dew_point_2m", + "uv": "uv_index", + "cloud": "cloud_cover", + "pressure": "pressure_msl", + "visibility": "visibility", +} + +var ( + validIcons = map[string]bool{"nerd": true, "emoji": true, "none": true} + validColors = map[string]bool{"auto": true, "always": true, "never": true} + validUnits = map[string]bool{"metric": true, "imperial": true, "si": true} + validLangs = map[string]bool{"en": true, "pl": true} +) + +// AllSpecies is every pollen taxon Open-Meteo reports for Europe. +var AllSpecies = []string{"grass", "birch", "alder", "mugwort", "ragweed", "olive"} + +// Config is the fully resolved settings for one run. +type Config struct { + Location string + Hours int + Units string + Columns []string + Icons string + Graph bool + GraphHeight int + Warnings bool + Pollen []string + Color string + DisplayLang string + + // Minimal strips everything that is not the forecast itself: sun times, the + // day summary and pollen. Set by -weather, for output meant to be piped to + // someone who did not ask for pollen counts. Flag only -- there is no config + // key, because it describes one invocation rather than a preference. + Minimal bool + + // ASCII restricts output to ASCII so an SMS stays in GSM-7 (160 characters + // per segment) instead of UCS-2 (70). One degree sign costs more than half + // the message. + ASCII bool +} + +// Default returns the built-in configuration, used when no file exists and as +// the base every file and flag overrides. +func Default() Config { + return Config{ + Hours: 12, + Units: "metric", + Columns: []string{"hour", "temp", "feels", "conditions", "mm", "rain"}, + Icons: "nerd", + Graph: true, + GraphHeight: 5, + Warnings: true, + Pollen: append([]string(nil), AllSpecies...), + Color: "auto", + DisplayLang: "en", + } +} + +// Path is the default location of the config file. +func Path() string { + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "prognosis", "config") + } + return filepath.Join(os.Getenv("HOME"), ".config", "prognosis", "config") +} + +// ValidColumns lists every column name, sorted, for error messages and docs. +func ValidColumns() []string { + names := make([]string, 0, len(columnFields)) + for k := range columnFields { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Fields returns the Open-Meteo hourly fields the selected columns need. +// Only what is displayed is requested, so a narrow table costs a small response. +func (c Config) Fields() []string { + seen := map[string]bool{} + var out []string + for _, col := range c.Columns { + f := columnFields[col] + if f == "" || seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + sort.Strings(out) + return out +} + +// Has reports whether a column is selected. +func (c Config) Has(column string) bool { + for _, col := range c.Columns { + if col == column { + return true + } + } + return false +} + +// Validate rejects unusable settings, naming the offending value and listing +// what would have been accepted. A silently blank column is worse than an error. +func (c Config) Validate() error { + for _, col := range c.Columns { + if _, ok := columnFields[col]; !ok { + return fmt.Errorf("unknown column %q; valid: %s", + col, strings.Join(ValidColumns(), ", ")) + } + } + if !validIcons[c.Icons] { + return fmt.Errorf("unknown icons %q; valid: emoji, nerd, none", c.Icons) + } + if !validColors[c.Color] { + return fmt.Errorf("unknown color %q; valid: auto, always, never", c.Color) + } + if !validUnits[c.Units] { + return fmt.Errorf("unknown units %q; valid: metric, imperial, si", c.Units) + } + if !validLangs[c.DisplayLang] { + return fmt.Errorf("unknown display_lang %q; valid: en, pl", c.DisplayLang) + } + if c.Hours < 1 { + return fmt.Errorf("hours must be at least 1, got %d", c.Hours) + } + if c.GraphHeight < 2 { + return fmt.Errorf("graph_height must be at least 2, got %d", c.GraphHeight) + } + for _, s := range c.Pollen { + if !contains(AllSpecies, s) { + return fmt.Errorf("unknown pollen species %q; valid: %s, all, none", + s, strings.Join(AllSpecies, ", ")) + } + } + return nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// Load reads a config file over the defaults. A missing file is not an error: +// the defaults stand, and the caller may write them out. +func Load(path string) (Config, error) { + cfg := Default() + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for line := 1; sc.Scan(); line++ { + text := strings.TrimSpace(sc.Text()) + if text == "" || strings.HasPrefix(text, "#") { + continue + } + // Trailing comments are allowed so the generated file can annotate keys. + if i := strings.Index(text, "#"); i >= 0 { + text = strings.TrimSpace(text[:i]) + } + key, value, ok := strings.Cut(text, "=") + if !ok { + return cfg, fmt.Errorf("%s:%d: expected KEY=VALUE, got %q", path, line, text) + } + if err := cfg.set(strings.TrimSpace(key), strings.TrimSpace(value)); err != nil { + return cfg, fmt.Errorf("%s:%d: %w", path, line, err) + } + } + return cfg, sc.Err() +} + +func (c *Config) set(key, value string) error { + switch key { + case "location": + c.Location = value + case "units": + c.Units = value + case "icons": + c.Icons = value + case "color": + c.Color = value + case "display_lang": + c.DisplayLang = value + case "ascii": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("ascii: %w", err) + } + c.ASCII = b + case "columns": + c.Columns = splitList(value) + case "pollen": + switch value { + case "all": + c.Pollen = append([]string(nil), AllSpecies...) + case "none": + c.Pollen = nil + default: + c.Pollen = splitList(value) + } + case "hours": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("hours: %q is not a number", value) + } + c.Hours = n + case "graph_height": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("graph_height: %q is not a number", value) + } + c.GraphHeight = n + case "graph": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("graph: %w", err) + } + c.Graph = b + case "warnings": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("warnings: %w", err) + } + c.Warnings = b + default: + return fmt.Errorf("unknown key %q", key) + } + return nil +} + +func parseBool(v string) (bool, error) { + switch strings.ToLower(v) { + case "true", "yes", "on", "1": + return true, nil + case "false", "no", "off", "0": + return false, nil + } + return false, fmt.Errorf("%q is not true or false", v) +} + +func splitList(v string) []string { + var out []string + for _, part := range strings.Split(v, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +const template = `# prognosis configuration +# +# One KEY=VALUE per line. '#' starts a comment. Values are not quoted. +# Command line flags override everything here. + +# Place to query. When empty, location= from ~/.wegorc is used, so prognosis +# and wego never disagree about where you are. +location=%s + +# Default span in hours. -n and -d override it. +hours=%d + +# metric (C, km/h, mm) | imperial (F, mph, inch) | si (C, m/s, mm) +units=%s + +# Columns, in order. Available: +# %s +columns=%s + +# Weather glyph set for the "icon" column: nerd | emoji | none. +# nerd is single-width and monochrome, so it follows the terminal palette. +# emoji are colour glyphs from a fallback font and are not all one cell wide. +icons=%s + +# Temperature chart under the table. +graph=%t +graph_height=%d + +# Official IMGW warnings for your powiat (Poland only). +warnings=%t + +# Pollen species to report, or "all" / "none". +pollen=%s + +# Restrict output to ASCII: no degree sign, no diacritics, no block drawing. +# For SMS, where one non-ASCII character cuts the segment from 160 to 70 chars. +ascii=%t + +# auto (colour when stdout is a terminal) | always | never +color=%s + +# Language for everything prognosis writes itself -- headers, condition names, +# labels, dates, pollen species: en | pl. IMGW publishes its warning text in +# Polish only, so that text stays Polish whatever this is set to. +display_lang=%s +` + +// WriteDefault writes a commented configuration file, creating parent +// directories. The generated file documents every key, so the config is +// discoverable without the README. +func WriteDefault(path string, c Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + pollen := "none" + if len(c.Pollen) > 0 { + pollen = strings.Join(c.Pollen, ",") + } + body := fmt.Sprintf(template, + c.Location, c.Hours, c.Units, + strings.Join(ValidColumns(), ", "), + strings.Join(c.Columns, ","), + c.Icons, c.Graph, c.GraphHeight, c.Warnings, pollen, c.ASCII, c.Color, c.DisplayLang) + return os.WriteFile(path, []byte(body), 0o644) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..06c0234 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,158 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadMissingFileKeepsDefaults(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "absent")) + if err != nil { + t.Fatalf("missing file should not be an error: %v", err) + } + if cfg.Hours != 12 || cfg.Icons != "nerd" { + t.Fatalf("defaults not returned: %+v", cfg) + } +} + +func TestLoadOverridesOnlyWhatIsSet(t *testing.T) { + cfg, err := Load(write(t, "hours=24\nicons=emoji\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Hours != 24 { + t.Errorf("hours = %d, want 24", cfg.Hours) + } + if cfg.Icons != "emoji" { + t.Errorf("icons = %q, want emoji", cfg.Icons) + } + // Untouched keys must keep their defaults. + if cfg.GraphHeight != 5 || !cfg.Graph { + t.Errorf("unset keys lost their defaults: %+v", cfg) + } +} + +func TestLoadCommentsAndBlanks(t *testing.T) { + cfg, err := Load(write(t, "\n# a comment\n\nhours=6 # trailing comment\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Hours != 6 { + t.Fatalf("hours = %d, want 6 (trailing comment must be stripped)", cfg.Hours) + } +} + +func TestLoadErrors(t *testing.T) { + for name, body := range map[string]string{ + "no equals": "hours 12\n", + "unknown key": "colour=always\n", + "not a number": "hours=soon\n", + "not a bool": "graph=maybe\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := Load(write(t, body)); err == nil { + t.Fatalf("expected an error for %q", body) + } + }) + } +} + +func TestPollenAllAndNone(t *testing.T) { + cfg, _ := Load(write(t, "pollen=all\n")) + if len(cfg.Pollen) != len(AllSpecies) { + t.Errorf("pollen=all gave %v", cfg.Pollen) + } + cfg, _ = Load(write(t, "pollen=none\n")) + if len(cfg.Pollen) != 0 { + t.Errorf("pollen=none gave %v", cfg.Pollen) + } +} + +func TestValidateNamesTheOffender(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "tempature"} + err := cfg.Validate() + if err == nil { + t.Fatal("expected an error for an unknown column") + } + if !strings.Contains(err.Error(), "tempature") { + t.Errorf("error must name the offending column, got: %v", err) + } + if !strings.Contains(err.Error(), "conditions") { + t.Errorf("error must list valid columns, got: %v", err) + } +} + +func TestValidateRejectsBadEnums(t *testing.T) { + for name, mutate := range map[string]func(*Config){ + "icons": func(c *Config) { c.Icons = "pictures" }, + "color": func(c *Config) { c.Color = "sometimes" }, + "units": func(c *Config) { c.Units = "furlongs" }, + "hours": func(c *Config) { c.Hours = 0 }, + "graph_height": func(c *Config) { c.GraphHeight = 1 }, + "pollen": func(c *Config) { c.Pollen = []string{"oak"} }, + } { + t.Run(name, func(t *testing.T) { + cfg := Default() + mutate(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatalf("expected %s to be rejected", name) + } + }) + } +} + +func TestDefaultIsValid(t *testing.T) { + if err := Default().Validate(); err != nil { + t.Fatalf("the built-in default must be valid: %v", err) + } +} + +func TestFieldsRequestsOnlySelectedColumns(t *testing.T) { + cfg := Default() + cfg.Columns = []string{"hour", "temp", "wind"} + got := strings.Join(cfg.Fields(), ",") + want := "temperature_2m,wind_speed_10m" + if got != want { + t.Fatalf("Fields() = %q, want %q", got, want) + } +} + +func TestFieldsDeduplicates(t *testing.T) { + cfg := Default() + // icon and conditions both come from weather_code. + cfg.Columns = []string{"icon", "conditions"} + if got := cfg.Fields(); len(got) != 1 || got[0] != "weather_code" { + t.Fatalf("Fields() = %v, want one weather_code", got) + } +} + +func TestWriteDefaultRoundTrips(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "config") + want := Default() + want.Location = "Krakow" + if err := WriteDefault(path, want); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatalf("the file we generate must parse: %v", err) + } + if got.Location != "Krakow" || got.Hours != want.Hours || got.Icons != want.Icons { + t.Fatalf("round trip changed values:\n got %+v\nwant %+v", got, want) + } + if err := got.Validate(); err != nil { + t.Fatalf("the file we generate must validate: %v", err) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..f0526be --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,196 @@ +// Package i18n holds the translations for everything prognosis writes itself: +// column headers, condition names, section labels, weekday and month names, and +// pollen species and bands. +// +// What is deliberately NOT translated: the body of an IMGW warning. IMGW +// publishes those in Polish only, and rendering an invented English version of +// an official warning would be worse than showing the original. In English mode +// the surrounding labels are English and the warning text stays Polish. +package i18n + +import ( + "fmt" + "time" +) + +// Lang is a supported display language. +type Lang string + +const ( + EN Lang = "en" + PL Lang = "pl" +) + +// Catalog is the set of strings for one language. +type Catalog struct { + lang Lang + + // Table headers, keyed by column name. + headers map[string]string + // WMO weather code descriptions. + conditions map[int]string + // Section labels and short words. + words map[string]string + // Pollen taxa. + species map[string]string + // Qualitative pollen levels. + bands map[string]string + + weekdays [7]string + months [12]string +} + +var catalogs = map[Lang]*Catalog{EN: english(), PL: polish()} + +// For returns the catalogue for a language, falling back to English so a +// mistyped language degrades to readable output rather than empty strings. +func For(lang string) *Catalog { + if c, ok := catalogs[Lang(lang)]; ok { + return c + } + return catalogs[EN] +} + +// Lang reports which language this catalogue is. +func (c *Catalog) Lang() Lang { return c.lang } + +// Header is the column heading for a column name. +func (c *Catalog) Header(column string) string { + if s, ok := c.headers[column]; ok { + return s + } + return column +} + +// Condition describes a WMO weather code. Unknown codes are reported as such +// rather than silently blank, so a new code in the API is visible. +func (c *Catalog) Condition(code int) string { + if s, ok := c.conditions[code]; ok { + return s + } + return fmt.Sprintf("code %d", code) +} + +// Word returns a label such as "day", "pollen" or "warnings". +func (c *Catalog) Word(key string) string { + if s, ok := c.words[key]; ok { + return s + } + return key +} + +// Species is the display name of a pollen taxon. +func (c *Catalog) Species(name string) string { + if s, ok := c.species[name]; ok { + return s + } + return name +} + +// Band is the display name of a qualitative pollen level. +func (c *Catalog) Band(key string) string { + if s, ok := c.bands[key]; ok { + return s + } + return key +} + +// Date formats a date as "Mon 10 Aug" in the catalogue's language. Go's time +// package has no locale support, so the names come from the catalogue. +func (c *Catalog) Date(t time.Time) string { + return fmt.Sprintf("%s %02d %s", c.weekdays[int(t.Weekday())], t.Day(), c.months[int(t.Month())-1]) +} + +func english() *Catalog { + return &Catalog{ + lang: EN, + headers: map[string]string{ + "hour": "hr", "icon": "", "temp": "temp", "feels": "feels", + "conditions": "conditions", "mm": "mm", "rain": "rain", + "wind": "wind", "gusts": "gusts", "dir": "dir", + "humidity": "hum", "dew": "dew", "uv": "uv", + "cloud": "cloud", "pressure": "hPa", "visibility": "vis", + }, + conditions: map[int]string{ + 0: "clear", 1: "mainly clear", 2: "part cloudy", 3: "overcast", + 45: "fog", 48: "rime fog", + 51: "lt drizzle", 53: "drizzle", 55: "hvy drizzle", + 56: "frz drizzle", 57: "frz drizzle", + 61: "lt rain", 63: "rain", 65: "hvy rain", + 66: "frz rain", 67: "frz rain", + 71: "lt snow", 73: "snow", 75: "hvy snow", 77: "snow grains", + 80: "lt showers", 81: "showers", 82: "hvy showers", + 85: "snow showers", 86: "hvy snow showers", + 95: "thunderstorm", 96: "storm + hail", 99: "storm + hail", + }, + words: map[string]string{ + "sun": "sun", "up": "up", "down": "down", + "day": "day", "dry": "dry", "pollen": "pollen", + "rainfall": "rain", "over": "over", "daylight": "daylight", + "of": "of", "max": "max", "level": "level", + "warnings": "warnings", "could_not_check": "could not check IMGW", + "poland_only": "IMGW covers Poland only", + "hours_available": "hours available, not", + "h_per_col": "h/col", "temp_row": "temp", "rain_row": "rain", + }, + species: map[string]string{ + "grass": "grass", "birch": "birch", "alder": "alder", + "mugwort": "mugwort", "ragweed": "ragweed", "olive": "olive", + }, + bands: map[string]string{ + "none": "none", "low": "low", "medium": "medium", + "high": "high", "very high": "very high", + }, + weekdays: [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}, + months: [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}, + } +} + +func polish() *Catalog { + return &Catalog{ + lang: PL, + headers: map[string]string{ + "hour": "godz", "icon": "", "temp": "temp", "feels": "odczuw", + "conditions": "warunki", "mm": "mm", "rain": "deszcz", + "wind": "wiatr", "gusts": "porywy", "dir": "kier", + "humidity": "wilg", "dew": "ros", "uv": "uv", + "cloud": "chmury", "pressure": "hPa", "visibility": "wid", + }, + // Terminology follows IMGW's own vocabulary where it has one, so the + // table and a warning describe the same weather in the same words. + conditions: map[int]string{ + 0: "bezchmurnie", 1: "gł. bezchmurnie", 2: "częśc. zachm.", 3: "zachmurzenie", + 45: "mgła", 48: "mgła osadz.", + 51: "słaba mżawka", 53: "mżawka", 55: "silna mżawka", + 56: "mżawka marzn.", 57: "mżawka marzn.", + 61: "słaby deszcz", 63: "deszcz", 65: "silny deszcz", + 66: "deszcz marzn.", 67: "deszcz marzn.", + 71: "słaby śnieg", 73: "śnieg", 75: "silny śnieg", 77: "ziarna śniegu", + 80: "słabe przelotne", 81: "przelotne", 82: "silne przelotne", + 85: "przelotny śnieg", 86: "silny przel. śnieg", + 95: "burza", 96: "burza z gradem", 99: "burza z gradem", + }, + words: map[string]string{ + "sun": "słońce", "up": "wsch", "down": "zach", + "day": "dzień", "dry": "sucho", "pollen": "pyłek", + "rainfall": "opad", "over": "przez", "daylight": "dnia", + "of": "z", "max": "maks", "level": "stopień", + "warnings": "ostrzeżenia", "could_not_check": "nie udało się sprawdzić IMGW", + "poland_only": "IMGW obejmuje tylko Polskę", + "hours_available": "godzin dostępnych, nie", + "h_per_col": "h/kol", "temp_row": "temp", "rain_row": "opad", + }, + species: map[string]string{ + "grass": "trawy", "birch": "brzoza", "alder": "olcha", + "mugwort": "bylica", "ragweed": "ambrozja", "olive": "oliwka", + }, + bands: map[string]string{ + "none": "brak", "low": "niskie", "medium": "średnie", + "high": "wysokie", "very high": "b. wysokie", + }, + weekdays: [7]string{"nd", "pn", "wt", "śr", "cz", "pt", "sb"}, + months: [12]string{"sty", "lut", "mar", "kwi", "maj", "cze", + "lip", "sie", "wrz", "paź", "lis", "gru"}, + } +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..2a6a617 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,88 @@ +package i18n + +import ( + "testing" + "time" + + "github.com/lukaszkasprzak/prognosis/internal/config" +) + +func TestForFallsBackToEnglish(t *testing.T) { + if For("klingon").Lang() != EN { + t.Fatal("an unknown language must degrade to English, not to empty strings") + } + if For("pl").Lang() != PL { + t.Fatal("pl must resolve to the Polish catalogue") + } +} + +// Every catalogue must cover every column, or a table in that language would +// silently fall back to the internal column name. +func TestEveryLanguageCoversEveryColumn(t *testing.T) { + for _, lang := range []Lang{EN, PL} { + c := For(string(lang)) + for _, col := range config.ValidColumns() { + if _, ok := c.headers[col]; !ok { + t.Errorf("%s: no header for column %q", lang, col) + } + } + } +} + +// The WMO codes must match across languages: a code described in English but +// not Polish would print "code 71" to a Polish reader. +func TestConditionCoverageMatchesAcrossLanguages(t *testing.T) { + en, pl := For("en"), For("pl") + for code := range en.conditions { + if _, ok := pl.conditions[code]; !ok { + t.Errorf("code %d described in English but not Polish", code) + } + } + for code := range pl.conditions { + if _, ok := en.conditions[code]; !ok { + t.Errorf("code %d described in Polish but not English", code) + } + } +} + +func TestWordAndSpeciesCoverageMatches(t *testing.T) { + en, pl := For("en"), For("pl") + for k := range en.words { + if _, ok := pl.words[k]; !ok { + t.Errorf("word %q missing from Polish", k) + } + } + for _, s := range config.AllSpecies { + if _, ok := en.species[s]; !ok { + t.Errorf("species %q missing from English", s) + } + if _, ok := pl.species[s]; !ok { + t.Errorf("species %q missing from Polish", s) + } + } + for _, b := range []string{"none", "low", "medium", "high", "very high"} { + if en.Band(b) == b && b != "none" && b != "low" && b != "medium" && b != "high" && b != "very high" { + t.Errorf("band %q missing from English", b) + } + if pl.Band(b) == b { + t.Errorf("band %q not translated to Polish", b) + } + } +} + +func TestUnknownCodeIsVisible(t *testing.T) { + got := For("en").Condition(4242) + if got != "code 4242" { + t.Fatalf("unknown codes must be visible, got %q", got) + } +} + +func TestDate(t *testing.T) { + d := time.Date(2026, 8, 10, 13, 0, 0, 0, time.UTC) // a Monday + if got, want := For("en").Date(d), "Mon 10 Aug"; got != want { + t.Errorf("en date = %q, want %q", got, want) + } + if got, want := For("pl").Date(d), "pn 10 sie"; got != want { + t.Errorf("pl date = %q, want %q", got, want) + } +} diff --git a/internal/imgw/imgw.go b/internal/imgw/imgw.go new file mode 100644 index 0000000..48ffe19 --- /dev/null +++ b/internal/imgw/imgw.go @@ -0,0 +1,156 @@ +// Package imgw fetches official Polish meteorological warnings and resolves a +// point to the powiat code those warnings are tagged with. +// +// Both services are public and need no key. +package imgw + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// Endpoints are variables so tests can serve recorded fixtures locally. +var ( + warningsURL = "https://danepubliczne.imgw.pl/api/data/warningsmeteo" + gugikURL = "https://services.gugik.gov.pl/uug/" +) + +// now is the clock, replaceable in tests: whether a warning has expired depends +// on it. +var now = time.Now + +// Timeout bounds every request. +var Timeout = 15 * time.Second + +// Status is the outcome of resolving a point to a powiat. +type Status int + +const ( + // StatusOK means the point resolved to a powiat code. + StatusOK Status = iota + // StatusOutside means GUGiK answered but knows no address there. It covers + // Poland only, so the point is abroad. + StatusOutside + // StatusError means the service could not be asked. + // + // This must never be conflated with StatusOutside: one means "no warnings + // apply here", the other "I do not know whether any apply". + StatusError +) + +// Warning is one IMGW warning in force. +type Warning struct { + Event string `json:"nazwa_zdarzenia"` + Level string `json:"stopien"` + Probability string `json:"prawdopodobienstwo"` + From string `json:"obowiazuje_od"` + To string `json:"obowiazuje_do"` + Text string `json:"tresc"` + Teryt []any `json:"teryt"` +} + +func fetch(rawURL string, params url.Values, into any) error { + host := "" + if u, err := url.Parse(rawURL); err == nil { + host = u.Host + } + full := rawURL + if len(params) > 0 { + full += "?" + params.Encode() + } + req, err := http.NewRequest("GET", full, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "prognosis/1.0") + resp, err := (&http.Client{Timeout: Timeout}).Do(req) + if err != nil { + return fmt.Errorf("cannot reach %s: %w", host, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, into) +} + +type gugikResponse struct { + Results map[string]struct { + Teryt string `json:"teryt"` + } `json:"results"` +} + +// Powiat resolves a point to its 4-digit TERYT powiat code via GUGiK. +// +// IMGW tags every warning with the powiat codes it covers, so this is what +// makes "warnings for my area" mean this area rather than the whole country. +func Powiat(lat, lon float64) (string, Status, error) { + var r gugikResponse + err := fetch(gugikURL, url.Values{ + "request": {"GetAddressReverse"}, + "location": {fmt.Sprintf("POINT(%.6f %.6f)", lon, lat)}, + "srid": {"4326"}, + // The default 100 m radius finds nothing in the mountains or deep + // countryside, which looks identical to being abroad and would suppress + // real warnings. GUGiK clamps this to its own 5 km maximum. + "radius": {"10000"}, + }, &r) + if err != nil { + return "", StatusError, err + } + for _, entry := range r.Results { + if len(entry.Teryt) >= 4 { + return entry.Teryt[:4], StatusOK, nil + } + } + return "", StatusOutside, nil +} + +// Warnings returns the warnings in force for a powiat. +func Warnings(powiat string) ([]Warning, error) { + var all []Warning + if err := fetch(warningsURL, nil, &all); err != nil { + return nil, err + } + cut := now() + var live []Warning + for _, w := range all { + if !w.covers(powiat) { + continue + } + // An expired warning is dropped; one whose date will not parse is kept, + // because showing a stale warning beats hiding a live one. + if to, err := time.ParseInLocation("2006-01-02 15:04:05", w.To, time.Local); err == nil { + if to.Before(cut) { + continue + } + } + live = append(live, w) + } + return live, nil +} + +func (w Warning) covers(powiat string) bool { + for _, a := range w.Teryt { + switch v := a.(type) { + case string: + if v == powiat { + return true + } + case float64: + if strconv.Itoa(int(v)) == powiat { + return true + } + } + } + return false +} diff --git a/internal/imgw/imgw_test.go b/internal/imgw/imgw_test.go new file mode 100644 index 0000000..ffc678e --- /dev/null +++ b/internal/imgw/imgw_test.go @@ -0,0 +1,190 @@ +package imgw + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if gotQuery != nil { + *gotQuery = r.URL.RawQuery + } + w.Write(body) + })) + t.Cleanup(srv.Close) + return srv +} + +func serveText(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func pinClock(t *testing.T, at time.Time) { + t.Helper() + old := now + now = func() time.Time { return at } + t.Cleanup(func() { now = old }) +} + +// A point inside Poland resolves to the first four digits of its TERYT code. +func TestPowiatTruncatesTerytToFourDigits(t *testing.T) { + srv := serve(t, "gugik_krakow.json", nil) + gugikURL = srv.URL + code, status, err := Powiat(50.0617, 19.9373) + if err != nil { + t.Fatal(err) + } + if status != StatusOK { + t.Fatalf("status = %v, want StatusOK", status) + } + if code != "1815" { + t.Fatalf("code = %q, want 1815 (first four digits of 126101)", code) + } +} + +// GUGiK answers a foreign point with HTTP 200 and no results. That is a real +// answer -- "not in Poland" -- and must never be confused with a failure, which +// is the difference between "no warnings apply" and "I do not know". +func TestPowiatOutsidePolandIsAnAnswerNotAnError(t *testing.T) { + srv := serve(t, "gugik_abroad.json", nil) + gugikURL = srv.URL + code, status, err := Powiat(52.52, 13.40) + if err != nil { + t.Fatalf("a valid 'no results' response must not be an error: %v", err) + } + if status != StatusOutside { + t.Fatalf("status = %v, want StatusOutside", status) + } + if code != "" { + t.Fatalf("code = %q, want empty", code) + } +} + +func TestPowiatUnreachableIsStatusError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + })) + srv.Close() // closed: the request cannot connect at all + gugikURL = srv.URL + _, status, err := Powiat(50, 21) + if status != StatusError { + t.Fatalf("status = %v, want StatusError", status) + } + if err == nil { + t.Fatal("expected an error describing the failure") + } +} + +// The default 100 m radius finds nothing in the mountains, which is +// indistinguishable from being abroad and would suppress real warnings. +func TestPowiatAsksForAWideRadius(t *testing.T) { + var query string + srv := serve(t, "gugik_krakow.json", &query) + gugikURL = srv.URL + if _, _, err := Powiat(50, 21); err != nil { + t.Fatal(err) + } + if !strings.Contains(query, "radius=10000") { + t.Fatalf("query %q must ask for a wide radius", query) + } +} + +func TestWarningsKeepOnlyThisPowiat(t *testing.T) { + pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) // nothing expired + body := `[ + {"nazwa_zdarzenia":"Upal","stopien":"1","obowiazuje_do":"2030-01-01 00:00:00","teryt":["1815","1234"]}, + {"nazwa_zdarzenia":"Burze","stopien":"2","obowiazuje_do":"2030-01-01 00:00:00","teryt":["2207"]} + ]` + srv := serveText(t, body) + warningsURL = srv.URL + + live, err := Warnings("1815") + if err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].Event != "Upal" { + t.Fatalf("got %+v, want only the warning covering 1815", live) + } +} + +func TestWarningsDropExpiredButKeepUnparseableDates(t *testing.T) { + pinClock(t, time.Date(2026, 8, 10, 12, 0, 0, 0, time.Local)) + body := `[ + {"nazwa_zdarzenia":"Wczorajsze","stopien":"1","obowiazuje_do":"2026-08-09 23:00:00","teryt":["1815"]}, + {"nazwa_zdarzenia":"Trwajace","stopien":"1","obowiazuje_do":"2026-08-10 20:00:00","teryt":["1815"]}, + {"nazwa_zdarzenia":"Bezdaty","stopien":"1","obowiazuje_do":"","teryt":["1815"]} + ]` + srv := serveText(t, body) + warningsURL = srv.URL + + live, err := Warnings("1815") + if err != nil { + t.Fatal(err) + } + var names []string + for _, w := range live { + names = append(names, w.Event) + } + got := strings.Join(names, ",") + // Unparseable is kept: showing a stale warning beats hiding a live one. + if got != "Trwajace,Bezdaty" { + t.Fatalf("got %q, want \"Trwajace,Bezdaty\"", got) + } +} + +// IMGW has been seen to encode TERYT codes as numbers as well as strings. +func TestWarningsMatchNumericTerytCodes(t *testing.T) { + pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) + srv := serveText(t, `[{"nazwa_zdarzenia":"X","obowiazuje_do":"2030-01-01 00:00:00","teryt":[1815]}]`) + warningsURL = srv.URL + live, err := Warnings("1815") + if err != nil { + t.Fatal(err) + } + if len(live) != 1 { + t.Fatalf("a numeric teryt entry must still match, got %d warnings", len(live)) + } +} + +func TestWarningsUnreachableIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + warningsURL = srv.URL + if _, err := Warnings("1815"); err == nil { + t.Fatal("expected an error: the caller must be able to say 'could not check'") + } +} + +// The recorded national feed must parse, and must not match a made-up powiat. +func TestWarningsParseTheRecordedFeed(t *testing.T) { + pinClock(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local)) + srv := serve(t, "warnings.json", nil) + warningsURL = srv.URL + + if _, err := Warnings("1815"); err != nil { + t.Fatalf("the recorded feed must parse: %v", err) + } + none, err := Warnings("9999") + if err != nil { + t.Fatal(err) + } + if len(none) != 0 { + t.Fatalf("powiat 9999 does not exist but matched %d warnings", len(none)) + } +} diff --git a/internal/imgw/testdata/gugik_abroad.json b/internal/imgw/testdata/gugik_abroad.json new file mode 100644 index 0000000..f6e58d8 --- /dev/null +++ b/internal/imgw/testdata/gugik_abroad.json @@ -0,0 +1 @@ +{"type":"address","max results limit":1,"radius":5000,"max polygon area":null,"returned objects":0,"results":null,"request time":0.00072391430536905923} \ No newline at end of file diff --git a/internal/imgw/testdata/warnings.json b/internal/imgw/testdata/warnings.json new file mode 100644 index 0000000..2f6b37f --- /dev/null +++ b/internal/imgw/testdata/warnings.json @@ -0,0 +1 @@ +[{"id":"Wr20260810041817563","nazwa_zdarzenia":"Burze","stopien":"2","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 miejscami burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h, a punktowo mo\u017cliwe porywy do oko\u0142o 100 km\/h. Lokalnie grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["2209","2210","2216","2808","2810","1462","2012","2062","2063","1427","2004","2006","2007","2813","2861","0406","0408","0462","1413","1415","1419","2806","2807","2809","2811","0402","1411","2207","2802","2818","2819","2862","0412","0417","1420","1422","1437","1461","2812","2814","2815","2816","1402","0405","2801","2803","2804","2805","2817"]},{"id":"Wr20260810041839823","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-10 22:00:00","obowiazuje_od":"2026-08-10 17:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do 25 mm oraz porywy wiatru do 85 km\/h. Mo\u017cliiwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["3028","0401","0404","0403","0407","0409","2205","2206","2213","2214","3023","3025","3026","3030","3012","3020","3064","3004","0410","0411","0413","0414","2261","2262","2264","3006","3003","3010","3021","3031","3009","2203","3027","0213","0461","0463","0464","2204","3007","3011","3013","3016","3061","3062","3063","2202","3001","0415","0416","0418","0419","3017","3018","3019","3022"]},{"id":"Wr20260810041856167","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 00:00:00","obowiazuje_od":"2026-08-10 18:00:00","opublikowano":"2026-08-10 06:18:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1003","1004","1011","1013","1014","1015","1418","1421","1424","1428","1465","1005","1061","1062","1063","1404","1432","1434","1435","1438","1001","1016","1019","1020","1021","1002","1006","1007","1008","1010","1405","1406","1408","1414"]},{"id":"Wr20260810041907317","nazwa_zdarzenia":"Burze","stopien":"1","prawdopodobienstwo":"70","obowiazuje_do":"2026-08-11 03:00:00","obowiazuje_od":"2026-08-10 19:00:00","opublikowano":"2026-08-10 06:19:00","tresc":"Prognozowane s\u0105 lokalne burze, kt\u00f3rym b\u0119d\u0105 towarzyszy\u0107 opady deszczu do oko\u0142o 20 mm oraz porywy wiatru do 80 km\/h. Mo\u017cliwy grad.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1401","1407","2001","2005","1423","1426","1430","1436","0613","1433","1463","2014","2010","2013","2061","1412","1417","2009","2011","0661","1403","1410","1416","0601","0608","0611","0616","0614","0615","1425","1429","1464","2002","2003","2008"]},{"id":"Sk20260808093414110","nazwa_zdarzenia":"Upa\u0142","stopien":"2","prawdopodobienstwo":"80","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-09 13:00:00","opublikowano":"2026-08-08 11:34:00","tresc":"Prognozuje si\u0119 upa\u0142y. Temperatura maksymalna niedziel\u0119 09.08 od 29\u00b0C do 31\u00b0C, w poniedzia\u0142ek 10.08 od 30\u00b0C do 33\u00b0C. Temperatura minimalna w nocy od 17\u00b0C do 19\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0812","3210","0211","0201","0802","0803","0804","0805","0807","0808","0809","0810","0811","0862","3212","0204","0264","0203","0801","0209","0216","0218","0220","0861","3206","0222","0223","0225","0262"]},{"id":"Sk20260809095307175","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 20:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:53:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 33\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["1808","1418","2607","2609","2610","2611","2612","0617","1002","1206","1409","1405","1410","1430","1438","1603","1818","2470","2608","2613","1412","2010","1602","1604","1605","1608","2468","2469","2471","2472","2473","2474","2475","0214","1425","1201","0609","1812","1864","2416","1062","0614","2406","1203","1204","1205","1207","1208","1209","1210","1212","1215","1216","1218","1219","1261","1262","1263","1601","1606","1607","1802","1803","1804","3020","3027","1005","1006","1007","0610","1805","1806","1807","1809","1810","1811","1813","1815","1816","1819","1820","1861","1862","1863","2401","2402","2403","2404","2405","2407","2408","2409","2410","2411","2412","2413","2414","2415","2417","2461","2462","1008","1009","1010","1011","1012","1013","1014","1015","3017","3018","0620","2478","2601","2606","1202","1814","1609","1610","1611","1661","2463","2464","2465","2466","2467","2661","1016","1017","1018","1019","1020","1021","1061","1063","1403","1406","2005","2013","3008","3009","0606","0607","1428","1429","1432","1433","1434","3007","1401","0616","0664","1213","1214","0202","0611","0618","0663","1001","1003","1004","2476","2477","2479","2602","2603","2604","2605","0224","0604","0605","0613","0615","0215","0612","3061","0208","0217","0602","0608","1407","1417","1421","1423","1426","1436","1463","1464","1465","2003"]},{"id":"Sk20260809095258478","nazwa_zdarzenia":"Upa\u0142","stopien":"1","prawdopodobienstwo":"85","obowiazuje_do":"2026-08-10 18:00:00","obowiazuje_od":"2026-08-10 11:00:00","opublikowano":"2026-08-09 11:52:00","tresc":"Prognozuje si\u0119 upa\u0142. Temperatura maksymalna wyniesie od 30\u00b0C do 32\u00b0C.","komentarz":"Brak.","biuro":"Centralne Biuro Prognoz Meteorologicznych w Warszawie","teryt":["0207","0210","0261","0265","3011","3014","3015","3016","1411","1424","3012","3024","3026","3063","0221","0401","1462","0206","3003","0212","0415","3021","3022","3025","3030","0405","0408","0409","0410","0411","0412","1427","1435","3005","3006","3010","0205","0226","1419","3001","3013","3023","3004","3028","3029","0219","0419","1404","1408","1414","1416","1420","3062","3064","0418","0461","0463","0464","1402","0403","0407","0213"]}] \ No newline at end of file diff --git a/internal/openmeteo/openmeteo.go b/internal/openmeteo/openmeteo.go new file mode 100644 index 0000000..b5220f4 --- /dev/null +++ b/internal/openmeteo/openmeteo.go @@ -0,0 +1,385 @@ +// Package openmeteo talks to Open-Meteo's forecast, air-quality and geocoding +// APIs. None of them needs a key. +package openmeteo + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/lukaszkasprzak/prognosis/internal/cache" +) + +// Endpoints are variables, not constants, so tests can point them at a local +// server serving recorded fixtures instead of the live APIs. +var ( + forecastURL = "https://api.open-meteo.com/v1/forecast" + airURL = "https://air-quality-api.open-meteo.com/v1/air-quality" + geoURL = "https://geocoding-api.open-meteo.com/v1/search" +) + +const ( + // Both APIs reject anything larger; the air-quality one is the stricter. + MaxForecastDays = 16 + MaxAirDays = 7 +) + +// now is the clock, replaceable in tests: the window these functions return +// depends on the hour, so a real clock would make the tests pass or fail +// depending on when they ran. +var now = time.Now + +// Timeout bounds every request. +var Timeout = 15 * time.Second + +// Row is one hour of forecast. Vals is keyed by Open-Meteo field name, so a +// column added to the config needs no change here. +type Row struct { + When time.Time + Vals map[string]float64 + Code int +} + +// Val returns a field, and whether it was present. +func (r Row) Val(field string) (float64, bool) { + v, ok := r.Vals[field] + return v, ok +} + +// Data is a forecast reduced to the requested window. +type Data struct { + TZ string + Rows []Row + Sun map[string][2]string // date -> {sunrise, sunset} as HH:MM + Daily map[string]float64 +} + +// DailyFields are the once-a-day figures shown in the summary line. +var DailyFields = []string{ + "temperature_2m_max", "temperature_2m_min", "precipitation_sum", + "precipitation_hours", "daylight_duration", "sunshine_duration", +} + +func get(rawURL string, params url.Values, into any) error { + host := "" + if u, err := url.Parse(rawURL); err == nil { + host = u.Host + } + client := &http.Client{Timeout: Timeout} + req, err := http.NewRequest("GET", rawURL+"?"+params.Encode(), nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "prognosis/1.0") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("cannot reach %s: %w", host, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading from %s: %w", host, err) + } + if err := json.Unmarshal(body, into); err != nil { + return fmt.Errorf("bad response from %s: %w", host, err) + } + return nil +} + +type geoResponse struct { + Results []struct { + Name string `json:"name"` + Country string `json:"country_code"` + Admin1 string `json:"admin1"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + } `json:"results"` +} + +// Candidate is one place the geocoder matched. Admin1 is the region, which is +// usually the only thing telling two places of the same name apart. +type Candidate struct { + Geo cache.Geo + Admin1 string +} + +// Geocode resolves a place name to every candidate the geocoder returned, best +// match first. Coordinates are kept for all of them: a caller that only knows +// the names of the alternatives cannot offer a choice between them, it can only +// guess. +func Geocode(place string) ([]Candidate, error) { + var r geoResponse + err := get(geoURL, url.Values{ + "name": {place}, + "count": {"5"}, + "format": {"json"}, + }, &r) + if err != nil { + return nil, err + } + if len(r.Results) == 0 { + return nil, fmt.Errorf("no place called %q found by Open-Meteo's geocoder", place) + } + out := make([]Candidate, 0, len(r.Results)) + for _, hit := range r.Results { + g := cache.Geo{Lat: hit.Latitude, Lon: hit.Longitude, Country: hit.Country} + g.Label = hit.Name + if hit.Country != "" { + g.Label += ", " + hit.Country + } + out = append(out, Candidate{Geo: g, Admin1: hit.Admin1}) + } + return out, nil +} + +type forecastResponse struct { + TZAbbrev string `json:"timezone_abbreviation"` + UTCOff int `json:"utc_offset_seconds"` + Hourly map[string]any `json:"hourly"` + Daily map[string]any `json:"daily"` + Error bool `json:"error"` + Reason string `json:"reason"` + _ map[string]float64 // keeps the shape obvious +} + +// unitParams maps our units setting onto Open-Meteo's, so rounding is done by +// the provider rather than by us. +func unitParams(units string) url.Values { + v := url.Values{} + switch units { + case "imperial": + v.Set("temperature_unit", "fahrenheit") + v.Set("wind_speed_unit", "mph") + v.Set("precipitation_unit", "inch") + case "si": + v.Set("wind_speed_unit", "ms") + } + return v +} + +// Forecast fetches `hours` hours from the current hour, in the location's own +// timezone, requesting only the fields asked for. +func Forecast(lat, lon float64, hours int, units string, fields []string) (*Data, error) { + need := append([]string{"weather_code"}, fields...) + sort.Strings(need) + need = dedupe(need) + + days := hours/24 + 2 // we start partway through today + if days > MaxForecastDays { + days = MaxForecastDays + } + params := url.Values{ + "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)}, + "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)}, + "hourly": {strings.Join(need, ",")}, + "daily": {"sunrise,sunset," + strings.Join(DailyFields, ",")}, + "forecast_days": {strconv.Itoa(days)}, + "timezone": {"auto"}, + } + for k, vs := range unitParams(units) { + params[k] = vs + } + + var r forecastResponse + if err := get(forecastURL, params, &r); err != nil { + return nil, err + } + if r.Error { + return nil, fmt.Errorf("open-meteo: %s", r.Reason) + } + + times := stringSlice(r.Hourly["time"]) + if len(times) == 0 { + return nil, fmt.Errorf("Open-Meteo returned no hourly data") + } + start := WindowStart(times, r.UTCOff) + + d := &Data{TZ: r.TZAbbrev, Sun: map[string][2]string{}, Daily: map[string]float64{}} + end := start + hours + if end > len(times) { + end = len(times) + } + series := map[string][]float64{} + for _, f := range need { + series[f] = floatSlice(r.Hourly[f]) + } + for i := start; i < end; i++ { + when, err := time.Parse("2006-01-02T15:04", times[i]) + if err != nil { + continue + } + row := Row{When: when, Vals: map[string]float64{}} + for f, vals := range series { + if i < len(vals) { + row.Vals[f] = vals[i] + } + } + if v, ok := row.Vals["weather_code"]; ok { + row.Code = int(v) + } + d.Rows = append(d.Rows, row) + } + if len(d.Rows) == 0 { + return nil, fmt.Errorf("no forecast hours left in the returned window") + } + + dates := stringSlice(r.Daily["time"]) + rises, sets := stringSlice(r.Daily["sunrise"]), stringSlice(r.Daily["sunset"]) + for i, day := range dates { + if i < len(rises) && i < len(sets) && len(rises[i]) >= 16 && len(sets[i]) >= 16 { + d.Sun[day] = [2]string{rises[i][11:16], sets[i][11:16]} + } + } + for _, f := range DailyFields { + if vals := floatSlice(r.Daily[f]); len(vals) > 0 { + d.Daily[f] = vals[0] + } + } + return d, nil +} + +// Pollen returns the peak per species over the window ahead. +func Pollen(lat, lon float64, hours int, species []string) (map[string]float64, error) { + if len(species) == 0 { + return map[string]float64{}, nil + } + fields := make([]string, 0, len(species)) + for _, s := range species { + fields = append(fields, s+"_pollen") + } + days := hours/24 + 2 + if days > MaxAirDays { + days = MaxAirDays + } + var r struct { + UTCOff int `json:"utc_offset_seconds"` + Hourly map[string]any `json:"hourly"` + } + err := get(airURL, url.Values{ + "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)}, + "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)}, + "hourly": {strings.Join(fields, ",")}, + "forecast_days": {strconv.Itoa(days)}, + "timezone": {"auto"}, + }, &r) + if err != nil { + return nil, err + } + + times := stringSlice(r.Hourly["time"]) + start := WindowStart(times, r.UTCOff) + // Pollen peaks around midday, so a three-hour request would understate the + // day. Always look at least twelve hours ahead. + span := hours + if span < 12 { + span = 12 + } + end := start + span + if end > len(times) { + end = len(times) + } + + peaks := map[string]float64{} + for _, s := range species { + vals := floatSlice(r.Hourly[s+"_pollen"]) + have := presentSlice(r.Hourly[s+"_pollen"]) + found := false + best := 0.0 + for i := start; i < end && i < len(vals); i++ { + if i < len(have) && !have[i] { + continue // null: no reading here + } + if !found || vals[i] > best { + best, found = vals[i], true + } + } + if found { + peaks[s] = best + } + } + return peaks, nil +} + +// WindowStart is the index of the first timestamp at or after now, in the +// location's timezone. +// +// Open-Meteo's hourly arrays begin at 00:00 local, so anything that slices from +// the front reports the small hours of this morning rather than the hours +// ahead. The offset comes from the response so this stays correct for a place +// in another timezone. +func WindowStart(times []string, utcOffsetSeconds int) int { + cut := now().UTC().Add(time.Duration(utcOffsetSeconds) * time.Second).Truncate(time.Hour) + for i, t := range times { + when, err := time.Parse("2006-01-02T15:04", t) + if err != nil { + continue + } + if !when.Before(cut) { + return i + } + } + return 0 +} + +func dedupe(in []string) []string { + seen := map[string]bool{} + out := in[:0] + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +func stringSlice(v any) []string { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, x := range raw { + s, _ := x.(string) + out = append(out, s) + } + return out +} + +func floatSlice(v any) []float64 { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]float64, 0, len(raw)) + for _, x := range raw { + f, _ := x.(float64) + out = append(out, f) + } + return out +} + +// presentSlice reports, per index, whether the JSON value was non-null. Pollen +// is null outside Europe, and treating that as 0.0 would report a confident +// "grass 0.0 none" where the truth is "no data". +func presentSlice(v any) []bool { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]bool, 0, len(raw)) + for _, x := range raw { + _, isNum := x.(float64) + out = append(out, isNum) + } + return out +} diff --git a/internal/openmeteo/openmeteo_test.go b/internal/openmeteo/openmeteo_test.go new file mode 100644 index 0000000..758920c --- /dev/null +++ b/internal/openmeteo/openmeteo_test.go @@ -0,0 +1,364 @@ +package openmeteo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// serve returns a server replying with a recorded fixture, and records the +// query it was asked for so tests can assert on the request as well as the +// parsing. +func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if gotQuery != nil { + *gotQuery = r.URL.RawQuery + } + w.Header().Set("Content-Type", "application/json") + w.Write(body) + })) + t.Cleanup(srv.Close) + return srv +} + +// fixtureStart is the first hour in the recorded forecast, so tests can pin the +// clock relative to real recorded data. +func fixtureStart(t *testing.T) time.Time { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "forecast.json")) + if err != nil { + t.Fatal(err) + } + var r struct { + Hourly struct { + Time []string `json:"time"` + } `json:"hourly"` + } + if err := json.Unmarshal(body, &r); err != nil { + t.Fatal(err) + } + when, err := time.Parse("2006-01-02T15:04", r.Hourly.Time[0]) + if err != nil { + t.Fatal(err) + } + return when +} + +func pinClock(t *testing.T, at time.Time) { + t.Helper() + old := now + now = func() time.Time { return at.UTC() } + t.Cleanup(func() { now = old }) +} + +// The hourly array begins at 00:00 local, so slicing from the front reports the +// small hours of this morning rather than the hours ahead -- the bug that made +// the Python version report the wrong pollen peak. +// +// It also pins the timezone contract: the clock is real UTC, and the offset in +// the response converts it to the *location's* local time. That is what makes +// "prognosis -l krakow" start at the right hour when run from another zone. +func TestForecastWindowStartsAtTheCurrentLocalHourNotMidnight(t *testing.T) { + offset := fixtureOffset(t) // +02:00 for the recorded Polish forecast + if offset == 0 { + t.Skip("fixture has no UTC offset; the conversion cannot be exercised") + } + // 11:00 UTC is 13:00 in a +02:00 location. + utcNoon := fixtureStart(t).Add(13*time.Hour - time.Duration(offset)*time.Second) + pinClock(t, utcNoon) + + srv := serve(t, "forecast.json", nil) + forecastURL = srv.URL + d, err := Forecast(50.0617, 19.9373, 6, "metric", []string{"temperature_2m"}) + if err != nil { + t.Fatal(err) + } + if got := d.Rows[0].When.Hour(); got != 13 { + t.Fatalf("first row is %02d:00, want 13:00 local (clock was %s UTC, offset %+ds)", + got, utcNoon.Format("15:04"), offset) + } + if len(d.Rows) != 6 { + t.Fatalf("got %d rows, want 6", len(d.Rows)) + } + // Rows must be consecutive hours from there. + for i, r := range d.Rows { + if want := 13 + i; r.When.Hour() != want { + t.Fatalf("row %d is %02d:00, want %02d:00", i, r.When.Hour(), want) + } + } +} + +// fixtureOffset is the recorded response's utc_offset_seconds. +func fixtureOffset(t *testing.T) int { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "forecast.json")) + if err != nil { + t.Fatal(err) + } + var r struct { + Offset int `json:"utc_offset_seconds"` + } + if err := json.Unmarshal(body, &r); err != nil { + t.Fatal(err) + } + return r.Offset +} + +func TestForecastRequestsOnlySelectedFields(t *testing.T) { + pinClock(t, fixtureStart(t)) + var query string + srv := serve(t, "forecast.json", &query) + forecastURL = srv.URL + + if _, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m", "wind_speed_10m"}); err != nil { + t.Fatal(err) + } + hourly := queryValue(t, query, "hourly") + for _, want := range []string{"temperature_2m", "wind_speed_10m", "weather_code"} { + if !strings.Contains(hourly, want) { + t.Errorf("hourly=%q is missing %q", hourly, want) + } + } + // A field nobody asked for costs response size for nothing. + for _, unwanted := range []string{"relative_humidity_2m", "pressure_msl", "uv_index"} { + if strings.Contains(hourly, unwanted) { + t.Errorf("hourly=%q requests %q, which was not selected", hourly, unwanted) + } + } +} + +func TestForecastUnitsArePassedToTheProvider(t *testing.T) { + pinClock(t, fixtureStart(t)) + cases := map[string]map[string]string{ + "metric": {"temperature_unit": "", "wind_speed_unit": ""}, + "imperial": {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph"}, + "si": {"wind_speed_unit": "ms"}, + } + for units, want := range cases { + t.Run(units, func(t *testing.T) { + var query string + srv := serve(t, "forecast.json", &query) + forecastURL = srv.URL + if _, err := Forecast(50, 21, 3, units, []string{"temperature_2m"}); err != nil { + t.Fatal(err) + } + for k, v := range want { + if got := queryValue(t, query, k); got != v { + t.Errorf("%s=%q, want %q", k, got, v) + } + } + }) + } +} + +func TestForecastRequestsEnoughDaysAndRespectsTheCap(t *testing.T) { + pinClock(t, fixtureStart(t)) + for hours, wantDays := range map[int]string{12: "2", 24: "3", 48: "4", 400: "16"} { + var query string + srv := serve(t, "forecast.json", &query) + forecastURL = srv.URL + if _, err := Forecast(50, 21, hours, "metric", []string{"temperature_2m"}); err != nil { + t.Fatal(err) + } + if got := queryValue(t, query, "forecast_days"); got != wantDays { + t.Errorf("%d hours asked for forecast_days=%s, want %s", hours, got, wantDays) + } + } +} + +func TestForecastParsesDailyAndSun(t *testing.T) { + pinClock(t, fixtureStart(t)) + srv := serve(t, "forecast.json", nil) + forecastURL = srv.URL + d, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m"}) + if err != nil { + t.Fatal(err) + } + if len(d.Sun) == 0 { + t.Error("no sunrise/sunset parsed") + } + for _, day := range d.Sun { + if len(day[0]) != 5 || len(day[1]) != 5 { + t.Errorf("sun times must be HH:MM, got %v", day) + } + } + for _, f := range DailyFields { + if _, ok := d.Daily[f]; !ok { + t.Errorf("daily field %q missing", f) + } + } +} + +func TestForecastErrors(t *testing.T) { + pinClock(t, fixtureStart(t)) + t.Run("http error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer srv.Close() + forecastURL = srv.URL + if _, err := Forecast(50, 21, 3, "metric", nil); err == nil { + t.Fatal("expected an error on HTTP 500") + } + }) + t.Run("bad json", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("{not json")) + })) + defer srv.Close() + forecastURL = srv.URL + if _, err := Forecast(50, 21, 3, "metric", nil); err == nil { + t.Fatal("expected an error on malformed JSON") + } + }) +} + +// Pollen peaks around midday, so a 3-hour request must still look 12 hours +// ahead or it understates the day for someone with an allergy. +func TestPollenAlwaysLooksAtLeastTwelveHoursAhead(t *testing.T) { + start := fixtureStart(t) + pinClock(t, start.Add(6*time.Hour)) + srv := serve(t, "pollen.json", nil) + airURL = srv.URL + + short, err := Pollen(50, 21, 3, []string{"grass"}) + if err != nil { + t.Fatal(err) + } + long, err := Pollen(50, 21, 12, []string{"grass"}) + if err != nil { + t.Fatal(err) + } + if short["grass"] != long["grass"] { + t.Fatalf("3-hour window gave %v but 12-hour gave %v; the short window must "+ + "still cover 12 hours", short["grass"], long["grass"]) + } +} + +// Outside Europe the API returns nulls. Treating those as 0.0 reports a +// confident "grass 0.0 none" where the truth is "no data". +func TestPollenNullsAreAbsentNotZero(t *testing.T) { + pinClock(t, fixtureStart(t)) + srv := serve(t, "pollen_nulls.json", nil) + airURL = srv.URL + peaks, err := Pollen(-54.8, -68.3, 12, []string{"grass"}) + if err != nil { + t.Fatal(err) + } + if v, ok := peaks["grass"]; ok { + t.Fatalf("grass reported as %v, but the fixture has no readings there", v) + } +} + +func TestPollenCapsDaysAtTheAirQualityLimit(t *testing.T) { + pinClock(t, fixtureStart(t)) + var query string + srv := serve(t, "pollen.json", &query) + airURL = srv.URL + if _, err := Pollen(50, 21, 15*24, []string{"grass"}); err != nil { + t.Fatal(err) + } + if got := queryValue(t, query, "forecast_days"); got != "7" { + t.Fatalf("forecast_days=%s, want 7 (the air-quality API rejects more)", got) + } +} + +func TestPollenWithNoSpeciesMakesNoRequest(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + airURL = srv.URL + peaks, err := Pollen(50, 21, 12, nil) + if err != nil || len(peaks) != 0 { + t.Fatalf("got %v, %v", peaks, err) + } + if called { + t.Error("requested pollen despite no species being selected") + } +} + +func TestGeocodeReportsAlternatives(t *testing.T) { + srv := serve(t, "geocode_ambiguous.json", nil) + geoURL = srv.URL + cands, err := Geocode("krakow") + if err != nil { + t.Fatal(err) + } + if len(cands) < 2 { + t.Fatalf("got %d candidates, want several: the fixture is ambiguous", len(cands)) + } + g := cands[0].Geo + if g.Country == "" || g.Label == "" { + t.Errorf("incomplete geo: %+v", g) + } + if !strings.Contains(g.Label, g.Country) { + t.Errorf("label %q should carry the country %q", g.Label, g.Country) + } +} + +// Selecting a place other than the first one is impossible unless its +// coordinates survive the call, which is exactly what the old API discarded. +func TestGeocodeKeepsCoordinatesForEveryCandidate(t *testing.T) { + srv := serve(t, "geocode_ambiguous.json", nil) + geoURL = srv.URL + cands, err := Geocode("krakow") + if err != nil { + t.Fatal(err) + } + for i, c := range cands { + if c.Geo.Lat == 0 || c.Geo.Lon == 0 { + t.Errorf("candidate %d (%s) has no coordinates, so it cannot be picked", i+1, c.Geo.Label) + } + if c.Admin1 == "" { + t.Errorf("candidate %d (%s) has no region, so the list cannot tell duplicates apart", i+1, c.Geo.Label) + } + } +} + +func TestGeocodeNoResultsIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"generationtime_ms":0.1}`)) + })) + defer srv.Close() + geoURL = srv.URL + if _, err := Geocode("zzzznowhere"); err == nil { + t.Fatal("expected an error when nothing matched") + } +} + +func TestWindowStartFallsBackToZeroWhenEverythingIsPast(t *testing.T) { + pinClock(t, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + if got := WindowStart([]string{"2026-08-10T00:00", "2026-08-10T01:00"}, 0); got != 0 { + t.Fatalf("got %d, want 0", got) + } +} + +func queryValue(t *testing.T, rawQuery, key string) string { + t.Helper() + for _, pair := range strings.Split(rawQuery, "&") { + k, v, _ := strings.Cut(pair, "=") + if k == key { + unescaped, err := urlUnescape(v) + if err != nil { + t.Fatal(err) + } + return unescaped + } + } + return "" +} + +func urlUnescape(s string) (string, error) { return url.QueryUnescape(s) } diff --git a/internal/openmeteo/testdata/forecast.json b/internal/openmeteo/testdata/forecast.json new file mode 100644 index 0000000..b8948f7 --- /dev/null +++ b/internal/openmeteo/testdata/forecast.json @@ -0,0 +1 @@ +{"latitude":50.061700,"longitude":19.937300,"generationtime_ms":1.4580488204956055,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","precipitation_probability":"%","weather_code":"wmo code"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"temperature_2m":[15.0,14.4,13.4,13.0,12.9,12.1,11.9,14.4,19.2,22.4,24.6,26.2,27.5,28.7,29.3,29.4,29.3,28.9,28.4,27.0,24.8,23.6,22.9,22.6,22.3,22.5,22.4,21.9,21.6,20.3,18.9,19.8,20.9,22.4,22.0,22.1,23.1,22.7,21.5,22.2,21.9,21.8,21.7,21.3,19.5,18.3,16.8,15.6],"apparent_temperature":[13.8,13.3,12.5,11.8,11.5,11.2,11.0,14.0,18.3,21.6,24.1,26.2,27.6,29.1,29.5,28.2,28.3,28.7,28.9,28.2,25.5,24.1,22.2,21.8,21.3,20.4,20.0,19.5,19.7,18.7,18.3,19.4,21.3,23.1,21.6,22.0,23.3,21.8,20.4,20.8,19.8,19.7,20.2,19.6,18.3,16.3,15.1,13.7],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00],"precipitation_probability":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,3,4,4,3,2,1,0,0,2,6,8,8,6,4,3,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"weather_code":[1,3,2,0,0,0,0,1,0,2,2,3,3,0,3,3,3,3,3,0,0,3,1,0,0,0,0,0,0,0,0,0,3,3,3,3,2,3,3,3,3,3,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","precipitation_hours":"h","daylight_duration":"s","sunshine_duration":"s"},"daily":{"time":["2026-08-10","2026-08-11"],"sunrise":["2026-08-10T05:15","2026-08-11T05:16"],"sunset":["2026-08-10T20:01","2026-08-11T19:59"],"temperature_2m_max":[29.4,23.1],"temperature_2m_min":[11.9,15.6],"precipitation_sum":[0.00,0.00],"precipitation_hours":[0.0,0.0],"daylight_duration":[53154.79,52962.40],"sunshine_duration":[43387.38,856.60]}} \ No newline at end of file diff --git a/internal/openmeteo/testdata/geocode_ambiguous.json b/internal/openmeteo/testdata/geocode_ambiguous.json new file mode 100644 index 0000000..62f9cda --- /dev/null +++ b/internal/openmeteo/testdata/geocode_ambiguous.json @@ -0,0 +1 @@ +{"results":[{"id":3094802,"name":"Krakow","latitude":50.06143,"longitude":19.93658,"elevation":219.0,"feature_code":"PPLA","country_code":"PL","admin1_id":858786,"admin2_id":6690154,"admin3_id":7531791,"timezone":"Europe/Warsaw","population":804237,"country_id":798544,"country":"Poland","admin1":"Lesser Poland","admin2":"Kraków","admin3":"Kraków"},{"id":5258888,"name":"Krakow","latitude":44.76166,"longitude":-88.25149,"elevation":237.0,"feature_code":"PPL","country_code":"US","admin1_id":5279468,"admin2_id":5265518,"admin3_id":5248361,"timezone":"America/Chicago","population":354,"postcodes":["54137"],"country_id":6252001,"country":"United States","admin1":"Wisconsin","admin2":"Oconto","admin3":"Town of Chase"},{"id":2884850,"name":"Krakow am See","latitude":53.65142,"longitude":12.26748,"elevation":48.0,"feature_code":"PPLA4","country_code":"DE","admin1_id":2872567,"admin3_id":8648342,"admin4_id":6550687,"timezone":"Europe/Berlin","population":3450,"country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Rostock","admin4":"Krakow am See"},{"id":2884852,"name":"Krakow","latitude":54.12372,"longitude":12.78276,"elevation":18.0,"feature_code":"PPL","country_code":"DE","admin1_id":2872567,"admin3_id":2843324,"admin4_id":6548111,"timezone":"Europe/Berlin","country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Vorpommern-Rügen","admin4":"Drechow"},{"id":3094801,"name":"Krąków","latitude":51.72921,"longitude":18.51595,"elevation":136.0,"feature_code":"PPL","country_code":"PL","admin1_id":3337493,"admin2_id":7531009,"admin3_id":7533290,"timezone":"Europe/Warsaw","country_id":798544,"country":"Poland","admin1":"Łódź Voivodeship","admin2":"Sieradz County","admin3":"Warta"}],"generationtime_ms":0.44548512} \ No newline at end of file diff --git a/internal/openmeteo/testdata/pollen.json b/internal/openmeteo/testdata/pollen.json new file mode 100644 index 0000000..eba1a73 --- /dev/null +++ b/internal/openmeteo/testdata/pollen.json @@ -0,0 +1 @@ +{"latitude":50.0,"longitude":21.8,"generationtime_ms":0.19991397857666016,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³","birch_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"grass_pollen":[7.2,6.7,5.4,2.6,1.7,2.6,2.6,6.2,6.3,6.5,6.9,6.6,5.5,4.7,4.5,4.7,5.0,4.9,5.5,6.0,10.6,6.4,6.3,6.5,6.4,5.4,5.4,6.0,5.4,4.9,4.8,4.6,4.6,3.8,3.4,3.5,3.5,3.8,3.9,3.8,3.7,4.4,4.2,4.9,5.8,5.8,6.6,8.0],"birch_pollen":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}} \ No newline at end of file diff --git a/internal/openmeteo/testdata/pollen_nulls.json b/internal/openmeteo/testdata/pollen_nulls.json new file mode 100644 index 0000000..46effa0 --- /dev/null +++ b/internal/openmeteo/testdata/pollen_nulls.json @@ -0,0 +1 @@ +{"latitude":-54.8,"longitude":-68.299995,"generationtime_ms":0.09846687316894531,"utc_offset_seconds":-10800,"timezone":"America/Argentina/Ushuaia","timezone_abbreviation":"GMT-3","elevation":52.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00"],"grass_pollen":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}} \ No newline at end of file diff --git a/internal/render/ascii.go b/internal/render/ascii.go new file mode 100644 index 0000000..4203bc9 --- /dev/null +++ b/internal/render/ascii.go @@ -0,0 +1,67 @@ +package render + +import "strings" + +// asciiMap is deliberately one rune to one ASCII character. +// +// Substitution happens after the table has been laid out, so anything that +// changed the number of characters would shear every column to its right. That +// constraint is why the wind arrows become single letters rather than the +// two-letter compass points that would read better. +var asciiMap = map[rune]string{ + // Polish diacritics, so a Polish forecast survives GSM-7. + 'ą': "a", 'ć': "c", 'ę': "e", 'ł': "l", 'ń': "n", + 'ó': "o", 'ś': "s", 'ź': "z", 'ż': "z", + 'Ą': "A", 'Ć': "C", 'Ę': "E", 'Ł': "L", 'Ń': "N", + 'Ó': "O", 'Ś': "S", 'Ź': "Z", 'Ż': "Z", + + // Wind arrows. The arrow points the way the wind blows, so v is a northerly. + '↑': "^", '↓': "v", '←': "<", '→': ">", + '↖': "\\", '↗': "/", '↙': "/", '↘': "\\", + + // Chart: blocks and the axis rule. + '█': "#", '▇': "#", '▆': "=", '▅': "=", + '▄': "_", '▃': ":", '▂': ".", '▁': ".", + '│': "|", +} + +// ASCII rewrites output to pure ASCII, one character for one character. +// +// The point is SMS: a single non-ASCII character forces the whole message from +// GSM-7 into UCS-2, which cuts a segment from 160 characters to 70. A degree +// sign alone therefore more than doubles the cost of sending a forecast. +// +// The degree sign becomes the unit letter, which is both ASCII and clearer to +// someone reading it cold: "29C" rather than "29". +func ASCII(s, units string) string { + degree := "C" + if units == "imperial" { + degree = "F" + } + runes := []rune(s) + var b strings.Builder + b.Grow(len(s)) + for i, r := range runes { + switch { + case r == '°': + // Prose from IMGW already writes "30°C"; appending the unit again + // would give "30CC". Only a bare degree sign gains the letter. + if i+1 < len(runes) && (runes[i+1] == 'C' || runes[i+1] == 'F') { + continue + } + b.WriteString(degree) + case r < 128: + b.WriteRune(r) + default: + if sub, ok := asciiMap[r]; ok { + b.WriteString(sub) + } else { + // Anything unmapped -- a place name in another script, a weather + // code description we have not transliterated -- becomes '?' + // rather than silently vanishing and misaligning the row. + b.WriteString("?") + } + } + } + return b.String() +} diff --git a/internal/render/ascii_test.go b/internal/render/ascii_test.go new file mode 100644 index 0000000..7e6a805 --- /dev/null +++ b/internal/render/ascii_test.go @@ -0,0 +1,55 @@ +package render + +import ( + "strings" + "testing" +) + +func TestASCIIIsPureASCII(t *testing.T) { + in := " ! Upał stopień 1\n 14 29° (28) zachmurzenie ↗\n 30°│███▄▄▁" + got := ASCII(in, "metric") + for _, r := range got { + if r > 127 { + t.Fatalf("non-ASCII %q survived in %q", r, got) + } + } +} + +// Substitution runs after layout, so it must not change the character count -- +// otherwise every column to the right of a degree sign shears. +func TestASCIIPreservesLength(t *testing.T) { + for _, in := range []string{ + " 14 29° (28) zachmurzenie", + " 30°│███▄▄▁▂", + " godz temp odczuw warunki", + " słońce 12h03m z 14h45m dnia", + } { + if got := ASCII(in, "metric"); len([]rune(got)) != len([]rune(in)) { + t.Errorf("length changed: %q (%d) -> %q (%d)", + in, len([]rune(in)), got, len([]rune(got))) + } + } +} + +// IMGW prose already writes "30°C"; the unit letter must not be doubled. +func TestASCIIDoesNotDoubleTheUnitInProse(t *testing.T) { + got := ASCII("temperatura od 30°C do 33°C", "metric") + if strings.Contains(got, "CC") { + t.Fatalf("doubled unit: %q", got) + } + if got != "temperatura od 30C do 33C" { + t.Fatalf("got %q", got) + } +} + +func TestASCIIUsesFahrenheitLetterInImperial(t *testing.T) { + if got := ASCII("85°", "imperial"); got != "85F" { + t.Fatalf("got %q, want 85F", got) + } +} + +func TestASCIIMarksUnmappedRunesRatherThanDroppingThem(t *testing.T) { + if got := ASCII("東京", "metric"); got != "??" { + t.Fatalf("got %q, want ??", got) + } +} diff --git a/internal/render/chart.go b/internal/render/chart.go new file mode 100644 index 0000000..cc20ca9 --- /dev/null +++ b/internal/render/chart.go @@ -0,0 +1,205 @@ +package render + +import ( + "fmt" + "math" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +const blocks = "▁▂▃▄▅▆▇█" + +// column is one drawn chart column. Keeping level, colour, rain and hour in one +// struct means they cannot drift apart, which four parallel slices would allow. +type column struct { + level int + style string + rain float64 + hour string +} + +// chart draws the temperature over several rows with a labelled axis. +// +// A one-row sparkline gives only 8 levels, so a single cold hour flattens the +// rest of the week into the top two blocks. Drawing over height rows with +// half-block cells gives height*2 levels, enough to see the daily rise and fall. +// Long spans are downsampled so the chart fits the terminal: a week is 168 +// hourly points and would otherwise wrap into mush. +func (x ctx) chart(v View) []string { + height := x.cfg.GraphHeight + const gutter = 5 // "NN°" label plus the axis rule + cols := x.width - gutter - 1 + if cols < 8 { + cols = 8 + } + + groups := buckets(min(len(v.Rows), cols), len(v.Rows)) + temps := make([]float64, len(groups)) + rains := make([]float64, len(groups)) + for i, g := range groups { + sum, maxRain := 0.0, 0.0 + for _, r := range v.Rows[g[0]:g[1]] { + t, _ := r.Val("temperature_2m") + sum += t + if mm, ok := r.Val("precipitation"); ok && mm > maxRain { + maxRain = mm + } + } + temps[i] = sum / float64(g[1]-g[0]) + rains[i] = maxRain + } + + lo, hi := temps[0], temps[0] + for _, t := range temps { + lo, hi = math.Min(lo, t), math.Max(hi, t) + } + span := hi - lo + if span == 0 { + span = 1 + } + steps := height * 2 + + // A 12-hour chart would occupy 12 of 80 columns; widen each point to use the + // terminal rather than leaving the curve cramped in the corner. + scale := cols / len(groups) + if scale < 1 { + scale = 1 + } + var drawn []column + for i, g := range groups { + lvl := int(math.Round((temps[i] - lo) / span * float64(steps))) + if lvl < 1 { + lvl = 1 // always one half-cell, so the coldest column still shows + } + for k := 0; k < scale; k++ { + drawn = append(drawn, column{ + level: lvl, + style: TempStyle(x.celsius(temps[i])), + rain: rains[i], + hour: v.Rows[g[0]].When.Format("15"), + }) + } + } + + out := []string{""} + for r := 0; r < height; r++ { + full := (height - r) * 2 + value := lo + (hi-lo)*float64(height-1-r)/float64(height-1) + label := " " + switch { + case r == 0: + label = x.c(TempStyle(x.celsius(hi)), PadLeft(fmt.Sprintf("%d°", Deg(hi)), 4)) + case r == height-1: + label = x.c(TempStyle(x.celsius(lo)), PadLeft(fmt.Sprintf("%d°", Deg(lo)), 4)) + case height >= 5 && r == height/2: + label = x.c(TempStyle(x.celsius(value)), PadLeft(fmt.Sprintf("%d°", Deg(value)), 4)) + } + cells := make([]Cell, 0, len(drawn)) + for _, d := range drawn { + switch { + case d.level >= full: + cells = append(cells, Cell{Style: d.style, Text: "█"}) + case d.level == full-1: + cells = append(cells, Cell{Style: d.style, Text: "▄"}) + default: + cells = append(cells, Cell{Text: " "}) + } + } + out = append(out, label+x.c(Dim, "│")+Paint(cells, x.c)) + } + + note := fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi)) + if len(groups) < len(v.Rows) { + note += fmt.Sprintf(" %.0f%s", float64(len(v.Rows))/float64(len(groups)), x.cat.Word("h_per_col")) + } + + // A flat row of empty blocks says nothing; only draw rain if there is any. + maxRain := 0.0 + for _, d := range drawn { + maxRain = math.Max(maxRain, d.rain) + } + if maxRain > 0 { + series := make([]float64, len(drawn)) + for i, d := range drawn { + series[i] = d.rain + } + out = append(out, x.c(Dim, PadLeft(x.cat.Word("rain_row"), 4)+"│")+ + x.c(Cyan, spark(series))+ + x.c(Dim, fmt.Sprintf(" %s %.1fmm", x.cat.Word("max"), maxRain))) + } + + every := scale * maxInt(1, ceilDiv(len(groups), 8)) // at most 8 labels + hours := make([]string, len(drawn)) + for i, d := range drawn { + hours[i] = d.hour + } + out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+axis(hours, every))) + out = append(out, x.c(Dim, strings.Repeat(" ", gutter)+note)) + return out +} + +// axis places hour labels under the chart, one character per column so they +// line up. A label that would run off the end is skipped rather than printed as +// a half label. +func axis(hours []string, every int) string { + line := []rune(strings.Repeat(" ", len(hours))) + for i := 0; i < len(hours); i += every { + label := []rune(hours[i]) + if i+len(label) > len(line) { + continue + } + copy(line[i:], label) + } + return string(line) +} + +// buckets splits total rows into count contiguous groups. +func buckets(count, total int) [][2]int { + out := make([][2]int, count) + for i := range out { + lo := i * total / count + hi := (i + 1) * total / count + if hi <= lo { + hi = lo + 1 + } + out[i] = [2]int{lo, hi} + } + return out +} + +// spark renders one block character per value, scaled to the series' own range. +func spark(values []float64) string { + lo, hi := values[0], values[0] + for _, v := range values { + lo, hi = math.Min(lo, v), math.Max(hi, v) + } + if hi-lo < 1e-9 { // flat: sit on the baseline rather than divide by zero + return strings.Repeat(string([]rune(blocks)[0]), len(values)) + } + runes := []rune(blocks) + step := (hi - lo) / float64(len(runes)-1) + var b strings.Builder + for _, v := range values { + b.WriteRune(runes[int(math.Round((v-lo)/step))]) + } + return b.String() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func ceilDiv(a, b int) int { return (a + b - 1) / b } + +var _ = openmeteo.Row{} diff --git a/internal/render/color.go b/internal/render/color.go new file mode 100644 index 0000000..cf2af89 --- /dev/null +++ b/internal/render/color.go @@ -0,0 +1,62 @@ +package render + +import "strings" + +// Colour uses ANSI slots 0-15 only -- codes 30-37 and 90-97, plus the +// attributes 1 (bold), 2 (dim) and 4 (underline). Never 256-colour indices: +// this runs on terminals whose palette remaps the low slots (the phone's is +// entirely green), where a hardcoded 38;5;196 would be the one off-palette +// thing on screen. +const ( + Reset = "0" + Bold = "1" + Dim = "2" + Underline = "4" + + Blue = "34" + Cyan = "36" + Green = "32" + Yellow = "33" + Red = "31" + BrightBlue = "94" + BrightRed = "91" + BrightWhite = "97" +) + +// Styler returns a function that wraps text in an ANSI code, or returns it +// unchanged when colour is off. +func Styler(colour bool) func(code, text string) string { + if !colour { + return func(_, text string) string { return text } + } + return func(code, text string) string { + if code == "" { + return text + } + return "\033[" + code + "m" + text + "\033[0m" + } +} + +// Cell is one character of chart output with the style it should carry. +type Cell struct { + Style string + Text string +} + +// Paint joins cells, emitting one escape sequence per run of identical style +// rather than one per character. A per-character version makes the chart around +// ten times larger for output that looks exactly the same. +func Paint(cells []Cell, c func(string, string) string) string { + var b strings.Builder + for i := 0; i < len(cells); { + j := i + var run strings.Builder + for j < len(cells) && cells[j].Style == cells[i].Style { + run.WriteString(cells[j].Text) + j++ + } + b.WriteString(c(cells[i].Style, run.String())) + i = j + } + return b.String() +} diff --git a/internal/render/icons.go b/internal/render/icons.go new file mode 100644 index 0000000..59ae3d3 --- /dev/null +++ b/internal/render/icons.go @@ -0,0 +1,84 @@ +package render + +// Weather glyphs per icon set, keyed by WMO code group. +// +// The "nerd" codepoints are from the Nerd Fonts Weather range (U+E300-U+E3E3), +// which JetBrains Mono Nerd Font and MesloLGS NF both carry. They are single +// cell and monochrome, so they take the terminal's foreground colour and do not +// break a remapped palette. +// +// The "emoji" set is colour and comes from a fallback font. Its glyphs are not +// all one cell wide -- see width.go -- which is why every pad goes through +// DisplayWidth. +var iconSets = map[string]map[string]string{ + "nerd": { + "clear": "", + "partly": "", + "cloudy": "", + "fog": "", + "drizzle": "", + "rain": "", + "snow": "", + "storm": "", + }, + "emoji": { + "clear": "☀", + "partly": "⛅", + "cloudy": "☁", + "fog": "\U0001F32B", + "drizzle": "\U0001F326", + "rain": "\U0001F327", + "snow": "\U0001F328", + "storm": "⛈", + }, +} + +// iconGroup maps a WMO weather code onto a glyph group. +func iconGroup(code int) string { + switch { + case code == 0 || code == 1: + return "clear" + case code == 2: + return "partly" + case code == 3: + return "cloudy" + case code == 45 || code == 48: + return "fog" + case code >= 51 && code <= 57: + return "drizzle" + case code >= 61 && code <= 67, code >= 80 && code <= 82: + return "rain" + case code >= 71 && code <= 77, code == 85 || code == 86: + return "snow" + case code >= 95: + return "storm" + } + return "cloudy" +} + +// Icon returns the glyph for a weather code in the named set. An unknown set, +// or "none", yields an empty string so the column simply renders blank. +func Icon(set string, code int) string { + glyphs, ok := iconSets[set] + if !ok { + return "" + } + return glyphs[iconGroup(code)] +} + +// IconWidth is the display width the icon column should reserve for a set. +// The emoji set contains wide glyphs, so its column is two cells even for the +// entries that happen to be one. +func IconWidth(set string) int { + glyphs, ok := iconSets[set] + if !ok { + return 0 + } + w := 1 + for _, g := range glyphs { + if gw := DisplayWidth(g); gw > w { + w = gw + } + } + return w +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..4417763 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,283 @@ +// Package render turns fetched weather into terminal output. It is pure: no +// network, no clock beyond what it is given, so every rule below is testable. +package render + +import ( + "fmt" + "math" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/config" + "github.com/lukaszkasprzak/prognosis/internal/i18n" + "github.com/lukaszkasprzak/prognosis/internal/imgw" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +// RainInterestPct is the chance of rain below which the mm and rain columns are +// hidden: on a dry day they are a block of zeroes that pushes real content +// sideways. +const RainInterestPct = 20 + +// View is everything one run needs to render. +type View struct { + Label string + TZ string + Rows []openmeteo.Row + Sun map[string][2]string + Daily map[string]float64 + Pollen map[string]float64 + Warnings []imgw.Warning + WarnNote string + WarnFailed bool +} + +type ctx struct { + cfg config.Config + cat *i18n.Catalog + c func(string, string) string + width int + wet bool +} + +// celsius converts a displayed temperature back to Celsius. +// +// IMGW's thresholds are defined in Celsius, so they must be compared in +// Celsius: a warning threshold does not move because the display was switched +// to Fahrenheit. Without this, 85F (a mild 29C) renders in the "IMGW would warn +// about this" red. +func (x ctx) celsius(v float64) float64 { + if x.cfg.Units == "imperial" { + return (v - 32) * 5 / 9 + } + return v +} + +// Render produces the whole output. +func Render(v View, cfg config.Config, width int, colour bool) string { + cx := ctx{ + cfg: cfg, + cat: i18n.For(cfg.DisplayLang), + c: Styler(colour), + width: width, + wet: isWet(v.Rows), + } + var out []string + out = append(out, cx.warnings(v)...) + out = append(out, cx.header(v)...) + out = append(out, cx.table(v)...) + if cfg.Graph { + out = append(out, cx.chart(v)...) + } + text := strings.Join(out, "\n") + // Applied last, one character for one, so the layout above is unaffected. + if cfg.ASCII { + text = ASCII(text, cfg.Units) + } + return text +} + +func isWet(rows []openmeteo.Row) bool { + for _, r := range rows { + if mm, ok := r.Val("precipitation"); ok && mm > 0 { + return true + } + if p, ok := r.Val("precipitation_probability"); ok && p >= RainInterestPct { + return true + } + } + return false +} + +// warnings renders IMGW warnings, or an explicit line saying why none are shown. +// +// WarnFailed must never look the same as "none in force": silence would be read +// as all-clear. +func (x ctx) warnings(v View) []string { + var out []string + switch { + case x.WarnDisabled(): + return nil + case v.WarnFailed: + out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+x.cat.Word("could_not_check"))) + case v.WarnNote != "": + out = append(out, x.c(Dim, " "+x.cat.Word("warnings")+": "+v.WarnNote)) + } + for _, w := range v.Warnings { + style := Yellow + switch w.Level { + case "2": + style = Red + case "3": + style = Bold + ";" + Red + } + head := fmt.Sprintf(" ! %s %s %s %s -> %s (%s%%)", + w.Event, x.cat.Word("level"), w.Level, + clip(w.From), clip(w.To), w.Probability) + out = append(out, x.c(style, head)) + for _, line := range wrap(w.Text, x.width) { + out = append(out, x.c(Dim, " "+line)) + } + } + if len(out) > 0 { + out = append(out, "") + } + return out +} + +// WarnDisabled reports whether warnings were switched off in config. +func (x ctx) WarnDisabled() bool { return !x.cfg.Warnings } + +// clip shortens "2026-08-10 11:00:00" to "08-10 11:00". +func clip(s string) string { + if len(s) >= 16 { + return s[5:16] + } + return s +} + +func (x ctx) header(v View) []string { + var out []string + first := v.Rows[0].When + title := v.Label + " " + x.cat.Date(first) + if x.cfg.Minimal { + // Just the place and the date: no sun times, no summary, no pollen. + return append(out, x.c(Bold, title)) + } + sun, hasSun := v.Sun[first.Format("2006-01-02")] + suffix := "" + if hasSun { + suffix = fmt.Sprintf(" %s %s %s %s", + sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down")) + } + tz := "" + if v.TZ != "" { + tz = " " + v.TZ + } + // Keep it to one line where it fits; a phone is narrow enough that it often + // does not, and a wrapped title reads worse than two deliberate lines. + if hasSun && DisplayWidth(title)+DisplayWidth(suffix)+DisplayWidth(tz) <= x.width { + out = append(out, x.c(Bold, title)+x.c(Dim, suffix+tz)) + } else { + out = append(out, x.c(Bold, title)+x.c(Dim, tz)) + if hasSun { + out = append(out, x.c(Dim, fmt.Sprintf(" %s %s %s %s %s", + x.cat.Word("sun"), sun[0], x.cat.Word("up"), sun[1], x.cat.Word("down")))) + } + } + + if len(v.Daily) > 0 { + var bits []string + lo, okLo := v.Daily["temperature_2m_min"] + hi, okHi := v.Daily["temperature_2m_max"] + if okLo && okHi { + bits = append(bits, fmt.Sprintf("%d-%d°", Deg(lo), Deg(hi))) + } + if mm, ok := v.Daily["precipitation_sum"]; ok { + if mm == 0 { + bits = append(bits, x.cat.Word("dry")) + } else { + bits = append(bits, fmt.Sprintf("%s %.1fmm %s %.0fh", + x.cat.Word("rainfall"), mm, x.cat.Word("over"), + v.Daily["precipitation_hours"])) + } + } + if sun, ok := v.Daily["sunshine_duration"]; ok { + if day, ok2 := v.Daily["daylight_duration"]; ok2 { + bits = append(bits, fmt.Sprintf("%s %s %s %s %s", + x.cat.Word("sun"), hm(sun), x.cat.Word("of"), hm(day), + x.cat.Word("daylight"))) + } + } + label := " " + Pad(x.cat.Word("day"), 6) + " " + // Joined with wide separators when it fits; only a line too long for the + // terminal is re-wrapped, and then on single spaces. + joined := strings.Join(bits, " ") + lines := []string{joined} + if DisplayWidth(label)+DisplayWidth(joined) > x.width { + lines = wrap(joined, x.width-DisplayWidth(label)) + } + for i, line := range lines { + prefix := label + if i > 0 { + prefix = strings.Repeat(" ", DisplayWidth(label)) + } + out = append(out, x.c(Dim, prefix+line)) + } + } + + if len(v.Pollen) > 0 { + var bits []string + for _, s := range sortedByValue(v.Pollen) { + band := PollenBand(s, v.Pollen[s]) + // Skip taxa that are simply absent, but never hide grass: it is the + // one someone may be allergic to and its absence is information. + if band == "none" && s != "grass" { + continue + } + text := fmt.Sprintf("%s %.1f", x.cat.Species(s), v.Pollen[s]) + if band != "" { + text += " " + x.cat.Band(band) + } + bits = append(bits, text) + } + if len(bits) > 0 { + if len(bits) > 4 { + bits = bits[:4] + } + out = append(out, x.c(Dim, " "+Pad(x.cat.Word("pollen"), 6)+" ")+strings.Join(bits, " ")) + } + } + return out +} + +func hm(seconds float64) string { + s := int(seconds) + return fmt.Sprintf("%dh%02dm", s/3600, (s%3600)/60) +} + +func sortedByValue(m map[string]float64) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && m[keys[j]] > m[keys[j-1]]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + return keys +} + +func wrap(text string, width int) []string { + if width < 8 { + width = 8 + } + var lines []string + var line string + for _, word := range strings.Fields(text) { + switch { + case line == "": + line = word + case DisplayWidth(line)+1+DisplayWidth(word) <= width: + line += " " + word + default: + lines = append(lines, line) + line = word + } + } + if line != "" { + lines = append(lines, line) + } + return lines +} + +func round1(v float64) string { return fmt.Sprintf("%.1f", v) } + +func compass(deg float64) string { + dirs := []string{"↓", "↙", "←", "↖", "↑", "↗", "→", "↘"} + i := int(math.Mod(math.Round(deg/45), 8)) + if i < 0 { + i += 8 + } + return dirs[i] +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..484a45b --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,287 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/lukaszkasprzak/prognosis/internal/config" + "github.com/lukaszkasprzak/prognosis/internal/imgw" + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +// row builds one hour. mm and pop default to dry. +func row(hour int, temp float64, code int, mm, pop float64) openmeteo.Row { + return openmeteo.Row{ + When: time.Date(2026, 8, 10, hour, 0, 0, 0, time.UTC), + Code: code, + Vals: map[string]float64{ + "temperature_2m": temp, + "apparent_temperature": temp, + "precipitation": mm, + "precipitation_probability": pop, + "weather_code": float64(code), + }, + } +} + +func testConfig() config.Config { + c := config.Default() + c.Columns = []string{"hour", "temp", "feels", "conditions", "mm", "rain"} + c.Graph = false + c.Icons = "none" + c.DisplayLang = "en" + return c +} + +func view(rows ...openmeteo.Row) View { + return View{Label: "Test, PL", Rows: rows, Sun: map[string][2]string{}} +} + +// On a dry day the mm and rain columns are a block of zeroes pushing the real +// content sideways. +func TestDryWindowHidesTheRainColumns(t *testing.T) { + dry := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 3, 0, 5)), testConfig(), 80, false) + if strings.Contains(dry, "mm") || strings.Contains(dry, "rain") { + t.Errorf("dry window still shows rain columns:\n%s", dry) + } + + wet := Render(view(row(12, 25, 3, 0, 0), row(13, 26, 61, 0.4, 80)), testConfig(), 80, false) + if !strings.Contains(wet, "mm") || !strings.Contains(wet, "rain") { + t.Errorf("wet window must show rain columns:\n%s", wet) + } +} + +// Probability alone is enough: rain that has not started yet still matters. +func TestProbabilityAloneBringsBackTheRainColumns(t *testing.T) { + v := view(row(12, 25, 3, 0, RainInterestPct), row(13, 26, 3, 0, RainInterestPct)) + if out := Render(v, testConfig(), 80, false); !strings.Contains(out, "rain") { + t.Errorf("%d%% chance must show the columns:\n%s", RainInterestPct, out) + } + below := view(row(12, 25, 3, 0, RainInterestPct-1), row(13, 26, 3, 0, 0)) + if out := Render(below, testConfig(), 80, false); strings.Contains(out, "rain") { + t.Errorf("below the threshold the columns must stay hidden:\n%s", out) + } +} + +// An unbroken column of "overcast" hides the hour it stops being overcast, +// which is the only interesting part. +func TestConditionsPrintOnlyWhenTheyChange(t *testing.T) { + v := view( + row(12, 25, 3, 0, 0), // overcast + row(13, 25, 3, 0, 0), // still overcast: blank + row(14, 25, 0, 0, 0), // clear: printed + row(15, 25, 0, 0, 0), // still clear: blank + ) + out := Render(v, testConfig(), 80, false) + if n := strings.Count(out, "overcast"); n != 1 { + t.Errorf("overcast appears %d times, want 1:\n%s", n, out) + } + if n := strings.Count(out, "clear"); n != 1 { + t.Errorf("clear appears %d times, want 1:\n%s", n, out) + } +} + +// Across a day boundary the conditions are repeated once, so a reader starting +// at the new day is not looking at a blank column. +func TestConditionsRepeatAfterADaySeparator(t *testing.T) { + next := row(0, 20, 3, 0, 0) + next.When = time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + v := view(row(23, 22, 3, 0, 0), next) + v.Sun["2026-08-11"] = [2]string{"05:16", "19:59"} + + out := Render(v, testConfig(), 80, false) + if n := strings.Count(out, "overcast"); n != 2 { + t.Errorf("conditions must repeat once per day, appeared %d times:\n%s", n, out) + } + if !strings.Contains(out, "Tue 11 Aug") { + t.Errorf("missing the day separator:\n%s", out) + } +} + +func TestFeelsLikeOnlyWhenItDiffers(t *testing.T) { + same := row(12, 25, 3, 0, 0) + diff := row(13, 25, 3, 0, 0) + diff.Vals["apparent_temperature"] = 28 + + out := Render(view(same, diff), testConfig(), 80, false) + if strings.Count(out, "(") != 1 { + t.Errorf("feels-like must appear once, only where it differs:\n%s", out) + } + if !strings.Contains(out, "(28)") { + t.Errorf("expected (28):\n%s", out) + } +} + +// The four warning states must stay distinguishable: silence read as all-clear +// is the failure that matters. +func TestWarningStatesAreDistinct(t *testing.T) { + cfg := testConfig() + base := view(row(12, 25, 3, 0, 0)) + + inForce := base + inForce.Warnings = []imgw.Warning{{ + Event: "Upal", Level: "1", Probability: "85", + From: "2026-08-10 11:00:00", To: "2026-08-10 20:00:00", + Text: "Prognozuje sie upal.", + }} + out := Render(inForce, cfg, 80, false) + if !strings.Contains(out, "Upal") || !strings.Contains(out, "level 1") { + t.Errorf("a live warning must be shown:\n%s", out) + } + + if out := Render(base, cfg, 80, false); strings.Contains(out, "warnings:") { + t.Errorf("checked-and-none must print nothing about warnings:\n%s", out) + } + + failed := base + failed.WarnFailed = true + if out := Render(failed, cfg, 80, false); !strings.Contains(out, "could not check") { + t.Errorf("a failed check must say so, not stay silent:\n%s", out) + } + + abroad := base + abroad.WarnNote = "IMGW covers Poland only" + if out := Render(abroad, cfg, 80, false); !strings.Contains(out, "Poland only") { + t.Errorf("an abroad location must explain itself:\n%s", out) + } +} + +func TestWarningsDisabledSuppressesEvenAFailure(t *testing.T) { + cfg := testConfig() + cfg.Warnings = false + v := view(row(12, 25, 3, 0, 0)) + v.WarnFailed = true + if out := Render(v, cfg, 80, false); strings.Contains(out, "could not check") { + t.Errorf("warnings=false must suppress the notice too:\n%s", out) + } +} + +// -weather strips everything that is not the forecast. +func TestMinimalStripsTheExtras(t *testing.T) { + cfg := testConfig() + cfg.Minimal = true + v := view(row(12, 25, 3, 0, 0)) + v.Sun["2026-08-10"] = [2]string{"05:15", "20:01"} + v.Daily = map[string]float64{"temperature_2m_min": 12, "temperature_2m_max": 30} + v.Pollen = map[string]float64{"grass": 10} + + out := Render(v, cfg, 80, false) + for _, unwanted := range []string{"sun", "up", "day", "pollen", "grass"} { + if strings.Contains(out, unwanted) { + t.Errorf("minimal output still contains %q:\n%s", unwanted, out) + } + } + if !strings.Contains(out, "Test, PL") || !strings.Contains(out, "25°") { + t.Errorf("minimal output must still carry place and forecast:\n%s", out) + } +} + +func chartOf(t *testing.T, hours int, width int) []string { + t.Helper() + cfg := testConfig() + cfg.Graph = true + var rows []openmeteo.Row + for i := 0; i < hours; i++ { + when := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour) + r := row(0, 20+float64(i%10), 3, 0, 0) + r.When = when + rows = append(rows, r) + } + out := Render(view(rows...), cfg, width, false) + return strings.Split(out, "\n") +} + +// A 12-hour chart drawn one column per hour would occupy 12 of 80 columns. +func TestChartWidensShortSpansToFillTheTerminal(t *testing.T) { + lines := chartOf(t, 12, 80) + var widest int + for _, l := range lines { + if strings.Contains(l, "│") { + if w := DisplayWidth(l); w > widest { + widest = w + } + } + } + if widest < 40 { + t.Fatalf("chart is only %d columns wide for a 12-hour span; it should widen", widest) + } +} + +// A week is 168 points and would wrap into mush; columns must cover several +// hours and the label must say so. +func TestChartDownsamplesLongSpansAndSaysSo(t *testing.T) { + out := strings.Join(chartOf(t, 168, 80), "\n") + if !strings.Contains(out, "h/col") { + t.Fatalf("a downsampled chart must disclose the ratio:\n%s", out) + } + for _, l := range strings.Split(out, "\n") { + if w := DisplayWidth(l); w > 80 { + t.Fatalf("chart line is %d columns wide, wider than the terminal:\n%s", w, l) + } + } +} + +func TestChartNeverExceedsTheTerminalWidth(t *testing.T) { + for _, width := range []int{32, 53, 80, 96} { + for _, hours := range []int{1, 6, 24, 72} { + lines := chartOf(t, hours, width) + // Only the chart: the table has fixed column widths and is measured + // separately, below. + for i, l := range lines { + isChart := strings.Contains(l, "│") || + (i >= len(lines)-2 && strings.TrimSpace(l) != "") + if !isChart { + continue + } + if w := DisplayWidth(l); w > width { + t.Errorf("width=%d hours=%d: chart line is %d columns:\n%s", width, hours, w, l) + } + } + } + } +} + +// The table has fixed column widths, so unlike the chart it does not shrink to +// fit. This pins the width the default column set needs: the phone is 53 +// columns, so there is headroom, but a narrower terminal will wrap and there is +// no code preventing it. Narrow the columns instead -- see -columns. +func TestTableMinimumWidthIsKnown(t *testing.T) { + cfg := testConfig() + out := Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false) + widest := 0 + for _, l := range strings.Split(out, "\n") { + if w := DisplayWidth(l); w > widest { + widest = w + } + } + const documented = 34 + if widest != documented { + t.Fatalf("the default table now needs %d columns, not the documented %d; "+ + "update the README if this is intended", widest, documented) + } + // A narrower column set must actually be narrower, or -columns is no remedy. + cfg.Columns = []string{"hour", "temp", "conditions"} + narrow := 0 + for _, l := range strings.Split(Render(view(row(12, 25, 3, 0, 0)), cfg, 80, false), "\n") { + if w := DisplayWidth(l); w > narrow { + narrow = w + } + } + if narrow >= documented { + t.Errorf("narrow column set is %d columns, no better than %d", narrow, documented) + } +} + +// A label that would run off the end is skipped, never printed as half a label. +func TestChartAxisLabelsAreWholeOrAbsent(t *testing.T) { + for _, hours := range []int{1, 2, 3, 5, 12, 24} { + lines := chartOf(t, hours, 40) + axis := lines[len(lines)-2] // axis sits above the range note + for _, field := range strings.Fields(axis) { + if len(field) != 2 { + t.Errorf("hours=%d: axis has a partial label %q in %q", hours, field, axis) + } + } + } +} diff --git a/internal/render/scale.go b/internal/render/scale.go new file mode 100644 index 0000000..a2eb2dc --- /dev/null +++ b/internal/render/scale.go @@ -0,0 +1,92 @@ +package render + +import "math" + +// Temperature bands. The two ends are IMGW's own warning criteria, so a red +// temperature means the met office would issue a warning about it rather than +// that it looked hot to whoever wrote this: +// +// Tmin <= -15 silny mroz, stopien 1 -> bright blue +// Tmax >= 30 upal, stopien 1 -> red +// Tmax > 35 the higher heat level -> bright red +// +// The splits between (0, 10, 20) are round numbers, not thresholds from any +// source; they only subdivide the range nobody warns about. +var tempBands = []struct { + below float64 + code string +}{ + {0, Blue}, {10, Cyan}, {20, Green}, {30, Yellow}, +} + +// Deg rounds to whole degrees. Rounding through int conversion avoids "-0", +// which is arithmetically fine and visually wrong. +func Deg(t float64) int { + return int(math.Round(t)) +} + +// TempStyle is the ANSI code for a temperature in Celsius. +// +// The warning edges are written out rather than folded into tempBands so they +// match IMGW's criteria exactly, inclusive and exclusive included. +// +// The value is rounded first, so the colour always matches the number printed +// beside it: otherwise -14.6 prints as "-15" in a different colour than a true +// -15 and looks like a rendering bug. +func TempStyle(celsius float64) string { + t := float64(Deg(celsius)) + switch { + case t <= -15: + return BrightBlue + case t > 35: + return BrightRed + case t >= 30: + return Red + } + for _, b := range tempBands { + if t < b.below { + return b.code + } + } + return Yellow +} + +// Pollen bands in grains/m3, from Polish clinical sources. Each entry is an +// upper bound (exclusive) and a band key; a nil bound means "everything above". +// +// grass -- alergen.info.pl symptom table: 20 = first nasal symptoms in 25% +// of sufferers, 50 = symptoms in all tested, 65 = intensified in +// over 75%, 120 = dyspnoea after 30 minutes of exposure. +// birch -- mp.pl: 80 provokes symptoms in over 95% of allergics. +// mugwort -- mp.pl: over 70 counts as high, intensified symptoms. +// +// Birch and mugwort have a single published anchor each, so they get a two-way +// split rather than four bands: their "low" is weaker evidence than grass's. +// Alder, olive and ragweed have no Polish threshold that could be sourced and +// are deliberately left unbanded rather than banded on a guess. +var pollenBands = map[string][]struct { + below float64 + band string +}{ + "grass": {{20, "low"}, {50, "medium"}, {65, "high"}, {math.Inf(1), "very high"}}, + "birch": {{80, "low"}, {math.Inf(1), "high"}}, + "mugwort": {{70, "low"}, {math.Inf(1), "high"}}, +} + +// PollenBand is the qualitative level for a count, or "" where no threshold +// exists for that species. +func PollenBand(species string, value float64) string { + if value < 1 { + return "none" + } + bands, ok := pollenBands[species] + if !ok { + return "" + } + for _, b := range bands { + if value < b.below { + return b.band + } + } + return "" +} diff --git a/internal/render/scale_test.go b/internal/render/scale_test.go new file mode 100644 index 0000000..9754bdc --- /dev/null +++ b/internal/render/scale_test.go @@ -0,0 +1,57 @@ +package render + +import ( + "testing" + + "github.com/lukaszkasprzak/prognosis/internal/config" +) + +// IMGW's criteria are in Celsius. Switching the display to Fahrenheit must not +// move the temperature at which the met office is said to warn. +func TestThresholdsAreComparedInCelsiusWhateverTheDisplayUnits(t *testing.T) { + metric := ctx{cfg: config.Config{Units: "metric"}} + imperial := ctx{cfg: config.Config{Units: "imperial"}} + + cases := []struct { + celsius float64 + fahrenheit float64 + want string + why string + }{ + {29, 84.2, Yellow, "below the upal threshold"}, + {30, 86, Red, "upal stopien 1 is Tmax >= 30C"}, + {36, 96.8, BrightRed, "the higher heat level is Tmax > 35C"}, + {-15, 5, BrightBlue, "silny mroz stopien 1 is Tmin <= -15C"}, + {-14, 6.8, Blue, "just above the frost threshold"}, + } + for _, c := range cases { + if got := TempStyle(metric.celsius(c.celsius)); got != c.want { + t.Errorf("%.0fC -> %s, want %s (%s)", c.celsius, got, c.want, c.why) + } + if got := TempStyle(imperial.celsius(c.fahrenheit)); got != c.want { + t.Errorf("%.1fF (=%.0fC) -> %s, want %s (%s)", + c.fahrenheit, c.celsius, got, c.want, c.why) + } + } +} + +func TestPollenBandEdges(t *testing.T) { + cases := []struct { + species string + value float64 + want string + }{ + {"grass", 0.5, "none"}, {"grass", 19, "low"}, {"grass", 20, "medium"}, + {"grass", 49, "medium"}, {"grass", 50, "high"}, {"grass", 64, "high"}, + {"grass", 65, "very high"}, {"grass", 200, "very high"}, + {"birch", 79, "low"}, {"birch", 80, "high"}, + {"mugwort", 69, "low"}, {"mugwort", 70, "high"}, + {"ragweed", 50, ""}, // no Polish threshold sourced: deliberately unbanded + {"alder", 500, ""}, + } + for _, c := range cases { + if got := PollenBand(c.species, c.value); got != c.want { + t.Errorf("PollenBand(%q, %v) = %q, want %q", c.species, c.value, got, c.want) + } + } +} diff --git a/internal/render/table.go b/internal/render/table.go new file mode 100644 index 0000000..3ade296 --- /dev/null +++ b/internal/render/table.go @@ -0,0 +1,240 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/lukaszkasprzak/prognosis/internal/openmeteo" +) + +// cell is one rendered table cell: text, its colour, and how it is aligned. +type cell struct { + text string + style string + left bool +} + +// colWidth is the reserved display width per column. +func (x ctx) colWidth(name string) int { + switch name { + case "hour": + // Three, not two: the Python leaves a double space after the hour. + return 3 + case "icon": + return IconWidth(x.cfg.Icons) + case "temp": + return 5 + case "feels": + return 6 + case "conditions": + return 16 + case "mm", "rain", "wind", "gusts", "humidity", "dew", "uv", "cloud", "visibility": + return 5 + case "dir": + return 3 + case "pressure": + return 6 + } + return 6 +} + +func (x ctx) leftAligned(name string) bool { + switch name { + case "hour", "icon", "temp", "feels", "conditions": + return true + } + return false +} + +// visible drops columns that have nothing to say: the icon column when icons +// are off, and the rain pair on a dry window. +func (x ctx) visible() []string { + var out []string + for _, name := range x.cfg.Columns { + if name == "icon" && x.cfg.Icons == "none" { + continue + } + if (name == "mm" || name == "rain") && !x.wet { + continue + } + out = append(out, name) + } + return out +} + +// row assembles one line from cells, padding each to its column width BEFORE +// colouring it. Escape sequences carry no display width, so padding a coloured +// string misaligns every column to its right -- invisible when piped, obvious +// in a terminal. +func (x ctx) row(cells map[string]cell) string { + var b strings.Builder + for _, name := range x.visible() { + c := cells[name] + w := x.colWidth(name) + padded := PadLeft(c.text, w) + if x.leftAligned(name) { + padded = Pad(c.text, w) + } + b.WriteString(" ") + b.WriteString(x.c(c.style, padded)) + } + return strings.TrimRight(b.String(), " ") +} + +func (x ctx) table(v View) []string { + out := []string{""} + + headers := map[string]cell{} + for _, name := range x.visible() { + headers[name] = cell{text: x.cat.Header(name), style: ""} + } + out = append(out, x.c(Underline, x.rowPlain(headers))) + + day := v.Rows[0].When.Format("2006-01-02") + prevCode := -1 + for i, r := range v.Rows { + if d := r.When.Format("2006-01-02"); d != day { + day = d + sep := " -- " + x.cat.Date(r.When) + " --" + if sun, ok := v.Sun[d]; ok { + sep += fmt.Sprintf(" %s %s / %s", x.cat.Word("sun"), sun[0], sun[1]) + } + out = append(out, x.c(Dim, sep)) + prevCode = -1 // repeat the conditions once per day for context + } + out = append(out, x.row(x.cells(r, i == 0, &prevCode))) + } + return out +} + +// rowPlain is the header row: padded like the data but never coloured per cell, +// so the underline runs unbroken across it. +func (x ctx) rowPlain(cells map[string]cell) string { + var b strings.Builder + for _, name := range x.visible() { + w := x.colWidth(name) + text := cells[name].text + padded := PadLeft(text, w) + if x.leftAligned(name) { + padded = Pad(text, w) + } + b.WriteString(" ") + b.WriteString(padded) + } + return b.String() +} + +func (x ctx) cells(r openmeteo.Row, isNow bool, prevCode *int) map[string]cell { + out := map[string]cell{} + temp, hasTemp := r.Val("temperature_2m") + + for _, name := range x.visible() { + switch name { + case "hour": + style := Reset + if isNow { + style = Bold + } + out[name] = cell{text: r.When.Format("15"), style: style} + + case "icon": + out[name] = cell{text: Icon(x.cfg.Icons, r.Code)} + + case "temp": + style := TempStyle(x.celsius(temp)) + // The current hour keeps its emphasis on top of the heat colour. + if isNow { + style = Bold + ";" + style + } + out[name] = cell{text: fmt.Sprintf("%d°", Deg(temp)), style: style} + + case "feels": + text := "" + if feels, ok := r.Val("apparent_temperature"); ok && hasTemp { + // Only shown when it differs; otherwise it is a column of noise. + if abs(feels-temp) >= 1 { + text = fmt.Sprintf("(%d)", Deg(feels)) + } + } + out[name] = cell{text: text, style: Dim} + + case "conditions": + text := "" + // Only when they change: an unbroken column of "overcast" hides the + // hour it stops being overcast, which is the only interesting part. + if r.Code != *prevCode { + text = Truncate(x.cat.Condition(r.Code), x.colWidth(name)) + } + out[name] = cell{text: text} + + case "mm": + mm, _ := r.Val("precipitation") + style := Dim + if mm > 0 { + style = Cyan + } + out[name] = cell{text: round1(mm), style: style} + + case "rain": + p, _ := r.Val("precipitation_probability") + style := Dim + if p >= 50 { + style = Yellow + } + out[name] = cell{text: fmt.Sprintf("%d%%", int(p)), style: style} + + case "wind": + v, _ := r.Val("wind_speed_10m") + out[name] = cell{text: fmt.Sprintf("%d", int(v))} + + case "gusts": + v, _ := r.Val("wind_gusts_10m") + style := "" + if v >= 60 { + style = Yellow + } + out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: style} + + case "dir": + v, _ := r.Val("wind_direction_10m") + out[name] = cell{text: compass(v)} + + case "humidity": + v, _ := r.Val("relative_humidity_2m") + out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim} + + case "dew": + v, _ := r.Val("dew_point_2m") + out[name] = cell{text: fmt.Sprintf("%d°", Deg(v)), style: Dim} + + case "uv": + v, _ := r.Val("uv_index") + style := Dim + if v >= 6 { + style = Yellow + } + out[name] = cell{text: round1(v), style: style} + + case "cloud": + v, _ := r.Val("cloud_cover") + out[name] = cell{text: fmt.Sprintf("%d%%", int(v)), style: Dim} + + case "pressure": + v, _ := r.Val("pressure_msl") + out[name] = cell{text: fmt.Sprintf("%d", int(v)), style: Dim} + + case "visibility": + v, _ := r.Val("visibility") + out[name] = cell{text: fmt.Sprintf("%.0fkm", v/1000), style: Dim} + } + } + *prevCode = r.Code + return out +} + +func abs(f float64) float64 { + if f < 0 { + return -f + } + return f +} diff --git a/internal/render/width.go b/internal/render/width.go new file mode 100644 index 0000000..c5676d5 --- /dev/null +++ b/internal/render/width.go @@ -0,0 +1,110 @@ +package render + +import ( + "strings" + "unicode" +) + +// wideRanges are the code point ranges this program can emit that occupy two +// terminal cells. Only the sets we actually produce are covered -- our own icon +// glyphs, plus the CJK blocks a place name could contain -- rather than the +// whole Unicode width table, which would be a dependency or a large generated +// file for no gain. +var wideRanges = [][2]rune{ + {0x1100, 0x115F}, // Hangul Jamo + {0x2329, 0x232A}, + {0x2E80, 0x303E}, // CJK radicals, Kangxi + {0x3041, 0x33FF}, // kana, CJK compatibility + {0x3400, 0x4DBF}, // CJK extension A + {0x4E00, 0x9FFF}, // CJK unified + {0xA000, 0xA4CF}, // Yi + {0xAC00, 0xD7A3}, // Hangul syllables + {0xF900, 0xFAFF}, // CJK compatibility ideographs + {0xFE30, 0xFE6F}, // CJK compatibility forms + {0xFF00, 0xFF60}, // fullwidth forms + {0xFFE0, 0xFFE6}, + {0x1F300, 0x1F64F}, // emoji: weather, faces + {0x1F680, 0x1F6FF}, // emoji: transport and symbols + {0x1F900, 0x1F9FF}, // supplemental symbols + {0x26C4, 0x26C8}, // snowman, thundercloud + {0x2614, 0x2615}, // umbrella with rain, hot beverage +} + +// ambiguousWide lists the individual code points we emit whose East-Asian width +// is Ambiguous but which terminals in this estate render as two cells. +var ambiguousWide = map[rune]bool{ + 0x26C5: true, // sun behind cloud + 0x26C8: true, // thunder cloud and rain +} + +func runeWidth(r rune) int { + switch { + case r == 0xFE0F: + // Variation Selector-16 requests emoji presentation. It has no width of + // its own; its effect is already counted on the base rune. + return 0 + case r == 0xFE0E: + return 0 + case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r): + return 0 // combining and formatting marks occupy no cell + case r == '‍': + return 0 // zero-width joiner + case r < 0x20: + return 0 + case ambiguousWide[r]: + return 2 + } + for _, rng := range wideRanges { + if r >= rng[0] && r <= rng[1] { + return 2 + } + } + return 1 +} + +// DisplayWidth is the number of terminal cells a string occupies. +// +// Neither len() nor a rune count will do: an emoji may be two cells, a +// variation selector is zero, and combining marks are zero. Padding with the +// wrong number shears every column to the right of it -- and only in a real +// terminal, never when the output is piped, which is what makes it easy to miss. +func DisplayWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// Pad returns s padded with spaces to at least w display cells (left aligned). +func Pad(s string, w int) string { + if n := w - DisplayWidth(s); n > 0 { + return s + strings.Repeat(" ", n) + } + return s +} + +// PadLeft returns s padded with spaces to at least w display cells (right aligned). +func PadLeft(s string, w int) string { + if n := w - DisplayWidth(s); n > 0 { + return strings.Repeat(" ", n) + s + } + return s +} + +// Truncate cuts s to at most w display cells, never splitting a rune. +func Truncate(s string, w int) string { + if DisplayWidth(s) <= w { + return s + } + out, used := make([]rune, 0, len(s)), 0 + for _, r := range s { + rw := runeWidth(r) + if used+rw > w { + break + } + out = append(out, r) + used += rw + } + return string(out) +} diff --git a/internal/render/width_test.go b/internal/render/width_test.go new file mode 100644 index 0000000..f20038e --- /dev/null +++ b/internal/render/width_test.go @@ -0,0 +1,112 @@ +package render + +import ( + "strings" + "testing" +) + +func TestDisplayWidth(t *testing.T) { + cases := []struct { + name string + in string + want int + }{ + {"ascii", "temp", 4}, + {"empty", "", 0}, + {"degree sign is one cell", "28°", 3}, + {"polish diacritics are one cell each", "słońce", 6}, + {"emoji with variation selector counts once", "☀️", 1}, + {"bare BMP symbol", "☀", 1}, + {"sun behind cloud is wide", "⛅", 2}, + {"rain cloud is wide", "\U0001F327", 2}, + {"nerd font glyph is one cell", "", 1}, + {"block drawing is one cell", "█", 1}, + {"combining acute adds nothing", "é", 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := DisplayWidth(c.in); got != c.want { + t.Errorf("DisplayWidth(%q) = %d, want %d", c.in, got, c.want) + } + }) + } +} + +// The invariant that actually matters: whatever goes in a column, the padded +// result occupies exactly the requested number of cells. If this fails, every +// column to the right shears -- but only in a real terminal. +func TestPadReachesExactWidthForEveryIconSet(t *testing.T) { + samples := []string{ + "clear", "", "28°", "słońce", + "☀️", "⛅", "\U0001F327", "⛈", // emoji set + "", "", "", // nerd set + } + for _, s := range samples { + for _, w := range []int{1, 4, 8, 16} { + got := Pad(s, w) + if DisplayWidth(s) <= w && DisplayWidth(got) != w { + t.Errorf("Pad(%q, %d) has width %d, want %d", s, w, DisplayWidth(got), w) + } + if !strings.HasPrefix(got, s) { + t.Errorf("Pad(%q, %d) = %q, must not alter the content", s, w, got) + } + } + } +} + +func TestPadLeft(t *testing.T) { + if got := PadLeft("5", 3); got != " 5" { + t.Fatalf("PadLeft = %q, want %q", got, " 5") + } + if got := PadLeft("⛅", 4); DisplayWidth(got) != 4 { + t.Fatalf("PadLeft of a wide glyph has width %d, want 4", DisplayWidth(got)) + } +} + +func TestPadDoesNotShrink(t *testing.T) { + if got := Pad("conditions", 4); got != "conditions" { + t.Fatalf("Pad must never truncate: got %q", got) + } +} + +func TestTruncateNeverSplitsARune(t *testing.T) { + if got := Truncate("słońce", 3); got != "sło" { + t.Errorf("Truncate = %q, want %q", got, "sło") + } + // A wide glyph that does not fit is dropped whole, not halved. + if got := Truncate("a⛅", 2); got != "a" { + t.Errorf("Truncate = %q, want %q", got, "a") + } +} + +func TestPaintGroupsRuns(t *testing.T) { + c := Styler(true) + cells := []Cell{ + {Style: "31", Text: "a"}, {Style: "31", Text: "b"}, {Style: "31", Text: "c"}, + {Style: "33", Text: "d"}, + } + got := Paint(cells, c) + if n := strings.Count(got, "\033["); n != 4 { // 2 opens + 2 resets + t.Fatalf("expected one escape pair per run, got %d escapes in %q", n, got) + } + if strings.Count(got, "\033[31m") != 1 { + t.Errorf("the three red cells must share one escape: %q", got) + } +} + +func TestPaintWithoutColourIsPlain(t *testing.T) { + c := Styler(false) + got := Paint([]Cell{{Style: "31", Text: "a"}, {Style: "33", Text: "b"}}, c) + if got != "ab" { + t.Fatalf("colour off must yield plain text, got %q", got) + } +} + +// Colour must never reach for a 256-colour index. +func TestNoExtendedColourCodes(t *testing.T) { + for _, code := range []string{Blue, Cyan, Green, Yellow, Red, BrightBlue, BrightRed, BrightWhite} { + if strings.Contains(code, "38;5;") || strings.Contains(code, "48;5;") { + t.Errorf("%q is a 256-colour index; only slots 0-15 are allowed", code) + } + } +} -- cgit v1.3