<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sampo Mannila</title>
    <description>The latest articles on DEV Community by Sampo Mannila (@sampomannila).</description>
    <link>https://dev.to/sampomannila</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4239065%2F50b700db-34b5-43f5-84e7-9274c7e2128b.png</url>
      <title>DEV Community: Sampo Mannila</title>
      <link>https://dev.to/sampomannila</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sampomannila"/>
    <language>en</language>
    <item>
      <title>Money math in plain JavaScript: lessons from building a loan calculator</title>
      <dc:creator>Sampo Mannila</dc:creator>
      <pubDate>Thu, 12 Nov 2026 13:06:13 +0000</pubDate>
      <link>https://dev.to/sampomannila/money-math-in-plain-javascript-lessons-from-building-a-loan-calculator-3d6b</link>
      <guid>https://dev.to/sampomannila/money-math-in-plain-javascript-lessons-from-building-a-loan-calculator-3d6b</guid>
      <description>&lt;p&gt;&lt;a href="https://laskuri.org/" rel="noopener noreferrer"&gt;Laskuri.org&lt;/a&gt; is a Finnish site with more than two dozen calculators: loans, compound interest, percentages, unit converters, fitness formulas. It is a static site with no framework and no bundler. Each calculator is one plain JavaScript file.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://laskuri.org/lainalaskuri/" rel="noopener noreferrer"&gt;loan calculator&lt;/a&gt; was the hardest one to get right. These are the things that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse numbers the way people type them
&lt;/h2&gt;

&lt;p&gt;Finnish users write &lt;code&gt;10 000,25&lt;/code&gt;, often with a non-breaking space, and negative rates sometimes arrive with a real minus sign (U+2212) pasted from a bank's page. &lt;code&gt;Number()&lt;/code&gt; rejects all of these. The parser accepts them and refuses anything ambiguous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;\&lt;span class="n"&gt;u2212&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; \&lt;span class="n"&gt;u00a0&lt;/span&gt;\&lt;span class="n"&gt;u202f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;+-&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?|&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;$&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;NaN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;1,000.00&lt;/code&gt; and &lt;code&gt;1e4&lt;/code&gt; return &lt;code&gt;NaN&lt;/code&gt; on purpose. Guessing which separator the user meant is worse than asking again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count in cents
&lt;/h2&gt;

&lt;p&gt;Every balance and payment is an integer number of cents. Interest is rounded once per month, the way a bank statement shows it, and the last payment is whatever clears the balance to exactly zero. With floating-point euros the schedule drifts by a cent somewhere around year fifteen, and users notice.&lt;/p&gt;

&lt;p&gt;Equal-principal loans have their own trap: dividing the amount by the number of months and rounding each instalment does not add up. The calculator rounds the cumulative principal instead and takes the difference, so the instalments always sum to the loan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use expm1 and log1p for the annuity
&lt;/h2&gt;

&lt;p&gt;The textbook annuity formula subtracts two numbers that are almost equal when the monthly rate is small. &lt;code&gt;Math.expm1&lt;/code&gt; and &lt;code&gt;Math.log1p&lt;/code&gt; exist for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;annuity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;annualRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;months&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;annualRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;months&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expm1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;months&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log1p&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zero-rate branch is not optional. Reference rates were negative for years, and a zero total rate is a real input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The effective rate has no formula
&lt;/h2&gt;

&lt;p&gt;The annual percentage rate, with fees included, is the rate at which the discounted payments equal the money the borrower actually received. There is no closed form, so the calculator brackets the answer by doubling and then bisects a hundred times. It is slower than Newton's method and never diverges, which matters more in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file for the browser and for the tests
&lt;/h2&gt;

&lt;p&gt;Each calculator file ends its pure part with two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="kd"&gt;module&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'undefined'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'undefined'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the browser the file carries on and wires up the form. In Node it stops after exporting the functions, so the tests need no bundler and no DOM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'known annuity benchmark: 100000, 5%, 30 years'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;annuity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;536.8216230121398&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site has over two hundred of these tests, run with Node's built-in test runner and no dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live rates without a fragile page
&lt;/h2&gt;

&lt;p&gt;The calculator pre-fills the Euribor reference rate from the Bank of Finland. That is the only server-side code on the site: one small PHP endpoint that fetches the published rates, caches them for six hours, and refuses to replace a cached quote with an older one.&lt;/p&gt;

&lt;p&gt;The page does not depend on it. A snapshot of the rates is embedded in the HTML at build time, the request to the endpoint has a timeout, and if both fail the user gets an example rate and a note asking them to type their own. The rate field stays editable in every case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the math in pure functions with no DOM access.&lt;/li&gt;
&lt;li&gt;Store money as integers.&lt;/li&gt;
&lt;li&gt;Test against a published benchmark, not against your own output.&lt;/li&gt;
&lt;li&gt;Treat every live data source as something that will be down one day.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>php</category>
    </item>
    <item>
      <title>The most important file in our repository is the one written for AI agents</title>
      <dc:creator>Sampo Mannila</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:37:35 +0000</pubDate>
      <link>https://dev.to/sampomannila/the-most-important-file-in-our-repository-is-the-one-written-for-ai-agents-14d</link>
      <guid>https://dev.to/sampomannila/the-most-important-file-in-our-repository-is-the-one-written-for-ai-agents-14d</guid>
      <description>&lt;p&gt;We run a network of Finnish-language guide sites from a single GitHub repository. Most of the day-to-day work is done by AI coding agents: content updates, new pages, fixes and deployments. I have written about the setup elsewhere. This post is about one file in it, the instruction file at the root of the repository, and what ended up in it after two months of real use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a file and not a prompt
&lt;/h2&gt;

&lt;p&gt;An agent starts every task from zero. It does not remember yesterday's mistake or last week's decision. Anything that should carry over has to be in the repository, where the next session will read it.&lt;/p&gt;

&lt;p&gt;We also use two different agents on the same repository. A shared file is the only place where both get the same rules.&lt;/p&gt;

&lt;p&gt;So the file is short, checked in, and reviewed like code. Almost every line in it is there because something went wrong once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules, and where they came from
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the status page first, not the history.&lt;/strong&gt; Early on, agents would read weeks of old task notes before touching anything. Now there is one short status page and a handoff note per site. The history exists, but nobody reads it for a routine change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An instruction to act covers the whole route to live.&lt;/strong&gt; If I ask for an update, the agent builds, checks, merges and deploys without asking again at each step. If I say "show me first", it stops there. Asking for the same permission twice is listed as an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the smallest change in the permanent source.&lt;/strong&gt; No rebuilding a site from scratch, no new import, no redesign on the side. This rule exists because agents are very willing to improve things nobody asked about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what changed, not everything.&lt;/strong&gt; A one-line text edit used to trigger browser tests on every page and a fresh backup. The file now says that verification follows the size of the change, and that a result already produced for the same version is not repeated by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates are facts.&lt;/strong&gt; A page keeps its verified first publication date. Only a page whose content really changed gets a new updated date. Build times and site-wide stamps are forbidden, because they make old content look new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partner placements are an agreement, not a layout.&lt;/strong&gt; The order of partners on a page and the offers shown come from a monthly register. A redesign may not move them, and last month's agreements are not carried over by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not rerun a failing job blindly.&lt;/strong&gt; When a deployment fails, the agent has to find the actual cause first. There is exactly one documented exception, for a known transient error from the hosting provider's API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One record per change.&lt;/strong&gt; The deployment run and its summary are the record. No separate documentation pull request, no status line for a routine edit, no copying the same report into three files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave the other agent's work alone.&lt;/strong&gt; Whoever gets the task finishes it. Unfinished branches belonging to the other agent are not touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rule that was missing
&lt;/h2&gt;

&lt;p&gt;Rules also have to be removed. We had a check that pinned certain files by checksum so that imports could be verified. One of the pinned files was the status page, which changes all the time. The pin went stale and started failing unrelated pull requests. The fix was not a better pin but taking the living document out of the list.&lt;/p&gt;

&lt;p&gt;The lesson I took from it: a guardrail on a file that is supposed to change is a bug, and the agents will dutifully work around it until someone deletes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would copy
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the file short enough that it is read in full every time.&lt;/li&gt;
&lt;li&gt;Write the reason next to the rule when the reason is not obvious.&lt;/li&gt;
&lt;li&gt;When an agent does something wrong twice, change the file, not the prompt.&lt;/li&gt;
&lt;li&gt;Review instruction changes in pull requests like any other code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agents are capable. What they lack is the context of last week, and this file is where that context lives.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>githubactions</category>
      <category>automation</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
