summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--LICENSE674
-rw-r--r--Makefile56
-rw-r--r--README.md189
-rw-r--r--cmd/prognosis/args_test.go49
-rw-r--r--cmd/prognosis/main.go441
-rw-r--r--cmd/prognosis/resolve_test.go171
-rw-r--r--cmd/prognosis/resolver.go67
-rw-r--r--cmd/prognosis/term_other.go6
-rw-r--r--cmd/prognosis/term_unix.go28
-rw-r--r--docs/superpowers/plans/2026-08-10-go-rewrite.md133
-rw-r--r--docs/superpowers/specs/2026-08-10-go-rewrite-design.md279
-rw-r--r--go.mod3
-rw-r--r--internal/cache/cache.go251
-rw-r--r--internal/cache/cache_test.go282
-rw-r--r--internal/config/config.go360
-rw-r--r--internal/config/config_test.go158
-rw-r--r--internal/i18n/i18n.go196
-rw-r--r--internal/i18n/i18n_test.go88
-rw-r--r--internal/imgw/imgw.go156
-rw-r--r--internal/imgw/imgw_test.go190
-rw-r--r--internal/imgw/testdata/gugik_abroad.json1
-rw-r--r--internal/imgw/testdata/warnings.json1
-rw-r--r--internal/openmeteo/openmeteo.go385
-rw-r--r--internal/openmeteo/openmeteo_test.go364
-rw-r--r--internal/openmeteo/testdata/forecast.json1
-rw-r--r--internal/openmeteo/testdata/geocode_ambiguous.json1
-rw-r--r--internal/openmeteo/testdata/pollen.json1
-rw-r--r--internal/openmeteo/testdata/pollen_nulls.json1
-rw-r--r--internal/render/ascii.go67
-rw-r--r--internal/render/ascii_test.go55
-rw-r--r--internal/render/chart.go205
-rw-r--r--internal/render/color.go62
-rw-r--r--internal/render/icons.go84
-rw-r--r--internal/render/render.go283
-rw-r--r--internal/render/render_test.go287
-rw-r--r--internal/render/scale.go92
-rw-r--r--internal/render/scale_test.go57
-rw-r--r--internal/render/table.go240
-rw-r--r--internal/render/width.go110
-rw-r--r--internal/render/width_test.go112
41 files changed, 6192 insertions, 0 deletions
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. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
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 <path>.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)
+ }
+ }
+}