<?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: HS Solution</title>
    <description>The latest articles on DEV Community by HS Solution (@hssolution).</description>
    <link>https://dev.to/hssolution</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%2F4186887%2Ff0957f18-0fab-410d-9485-c244f5b2d8d7.jpg</url>
      <title>DEV Community: HS Solution</title>
      <link>https://dev.to/hssolution</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hssolution"/>
    <language>en</language>
    <item>
      <title>Before the final invoice: a 10-point acceptance checklist for outsourced software</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 16 Oct 2026 22:33:27 +0000</pubDate>
      <link>https://dev.to/hssolution/before-the-final-invoice-a-10-point-acceptance-checklist-for-outsourced-software-2fla</link>
      <guid>https://dev.to/hssolution/before-the-final-invoice-a-10-point-acceptance-checklist-for-outsourced-software-2fla</guid>
      <description>&lt;p&gt;Most outsourcing disputes I've seen don't start during development. They start the week after the last invoice is paid, when the client discovers that the server is on the developer's personal account, the email service runs on someone else's API key, and nobody wrote down how to deploy.&lt;/p&gt;

&lt;p&gt;We run a small development studio, so we sit on the delivering side of this. Over time we turned "what should be true before the final payment" into a written acceptance checklist that we send to clients &lt;em&gt;before&lt;/em&gt; delivery. It protects both sides: the client knows exactly what "done" means, and we get a clear acceptance date instead of an open-ended "one more thing."&lt;/p&gt;

&lt;p&gt;Here is the short version. If you freelance, feel free to copy it into your own contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10 checks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Every feature, one row in a table.&lt;/strong&gt; Copy the features from the quote or spec into a table and mark each row &lt;em&gt;pass&lt;/em&gt; or &lt;em&gt;needs fixing&lt;/em&gt;. Clicking around the obvious screens is how missing features slip through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Real devices, real browsers.&lt;/strong&gt; Run the core flows on what users will actually use: an iPhone with Safari, a mid-range Android phone, Chrome on desktop. "Works on my machine" is not acceptance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The unhappy paths.&lt;/strong&gt; Submit an empty form, type a wrong password, go offline. A clear message is a pass. A blank page or a raw stack trace is a fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Accounts in the client's name.&lt;/strong&gt; Admin panel, server, database, email sending, maps, payments: each should sign in with the client's own email, not a borrowed developer account. Change passwords after handover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Source, repo, domain, server.&lt;/strong&gt; The full source lives in a repository the client owns. The domain registrant and hosting account are in the client's name. Compare against the contract line by line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Deployment is written down.&lt;/strong&gt; Install, configure, build, upload, restart. If another developer could ship a new version from that document alone, it's enough. Secrets go over a separate channel, never in the README.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Personal data and backups.&lt;/strong&gt; What is stored where? Are passwords hashed? Does the privacy policy match what the code actually does? Do backups run automatically, and has anyone ever &lt;em&gt;restored&lt;/em&gt; one?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. The bug-fix window is in writing.&lt;/strong&gt; How many days of free fixes, starting from which date (acceptance or delivery), and what counts as a bug versus a new feature request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Licenses for fonts, images and libraries.&lt;/strong&gt; A list of every font, photo and icon, whether commercial use is allowed, and whether credit is required. "Found it on Google Images" turns into a legal email later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Basic performance.&lt;/strong&gt; How fast the first screen appears on mobile data, whether images are oversized, whether text is readable on a phone. If the contract has numbers, test against those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tie the final payment to acceptance
&lt;/h2&gt;

&lt;p&gt;This is the part that actually prevents the arguments. Put three things in the contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a review period (for example, 7 days after delivery),&lt;/li&gt;
&lt;li&gt;how fix requests are filed (in writing, against the checklist rows),&lt;/li&gt;
&lt;li&gt;and that the final payment is due a few days after the acceptance date, which is the day every row reads &lt;em&gt;pass&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The filled-in table becomes the acceptance record. No one has to remember what was agreed in a call.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is general information, not legal advice. Have important contracts reviewed by a professional.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Full version
&lt;/h2&gt;

&lt;p&gt;The longer guide has an example for each check and sample contract wording: &lt;a href="https://noligo.co.kr/en/guide/acceptance-checklist/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=acceptance-checklist-devto" rel="noopener noreferrer"&gt;Acceptance Checklist: 10 Things to Verify Before the Final Payment&lt;/a&gt;. It's on our studio site (HS Solution), so yes, that's our page.&lt;/p&gt;

&lt;p&gt;What's on your own handover list that isn't here? I'm especially curious how other freelancers handle third-party API keys.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was written by an AI assistant from the checklist we use in our own projects.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>productivity</category>
      <category>career</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One modulo gives you a Korean Saju day pillar (and Seoul's 1900 time zone breaks it)</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Fri, 16 Oct 2026 16:35:20 +0000</pubDate>
      <link>https://dev.to/hssolution/one-modulo-gives-you-a-korean-saju-day-pillar-and-seouls-1900-time-zone-breaks-it-3c0n</link>
      <guid>https://dev.to/hssolution/one-modulo-gives-you-a-korean-saju-day-pillar-and-seouls-1900-time-zone-breaks-it-3c0n</guid>
      <description>&lt;p&gt;In Korean Saju (the "Four Pillars" of birth), the &lt;strong&gt;day pillar&lt;/strong&gt; is the one part of the chart that is pure arithmetic. No solar terms, no lunar calendar, no lookup tables — just a 60-day cycle that has been running without a break for a very long time.&lt;/p&gt;

&lt;p&gt;So it should be a one-liner. It mostly is. But the obvious JavaScript version gives the wrong answer for &lt;strong&gt;every date&lt;/strong&gt; if your machine is set to Korean time. Here is the math, the bug, and the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-day cycle
&lt;/h2&gt;

&lt;p&gt;Each day gets a pair: one of 10 Heavenly Stems and one of 12 Earthly Branches. Both advance by one every day, so the pair repeats every lcm(10, 12) = 60 days.&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;const&lt;/span&gt; &lt;span class="nx"&gt;STEMS&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;甲乙丙丁戊己庚辛壬癸&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Gap, Eul, Byeong, Jeong, Mu, Gi, Gyeong, Sin, Im, Gye&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BRANCHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;子丑寅卯辰巳午未申酉戌亥&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Ja, Chuk, In, Myo, Jin, Sa, O, Mi, Sin, Yu, Sul, Hae&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because both counters move together, a single index &lt;code&gt;i&lt;/code&gt; in &lt;code&gt;0..59&lt;/code&gt; is enough: the stem is &lt;code&gt;i % 10&lt;/code&gt; and the branch is &lt;code&gt;i % 12&lt;/code&gt;. Index 0 is 甲子 (Gap-ja), index 59 is 癸亥 (Gye-hae).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick an anchor, count days
&lt;/h2&gt;

&lt;p&gt;All you need is one date whose pillar you know. 31 January 1900 was 甲辰 (Gap-jin), index 40. From there:&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;dayPillar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&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="nx"&gt;days&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// works for dates before 1900 too&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;STEMS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BRANCHES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;dayPillar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "戊午"  (Mu-o, 55th day)&lt;/span&gt;
&lt;span class="nf"&gt;dayPillar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "己未"  (Gi-mi)&lt;/span&gt;
&lt;span class="nf"&gt;dayPillar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1949&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "甲子"  (start of a cycle)&lt;/span&gt;
&lt;span class="nf"&gt;dayPillar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "甲子" — also a cycle start&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;((x % 60) + 60) % 60&lt;/code&gt; — JavaScript's &lt;code&gt;%&lt;/code&gt; keeps the sign, so dates before the anchor would give a negative index without it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Date.UTC&lt;/code&gt; and &lt;code&gt;Math.round&lt;/code&gt; — this is the part people skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bug: local time + &lt;code&gt;Math.floor&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The "obvious" version uses local dates:&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="c1"&gt;// Don't do this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&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;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran both versions for 15 May 1990 under different &lt;code&gt;TZ&lt;/code&gt; settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TZ&lt;/th&gt;
&lt;th&gt;local-date version&lt;/th&gt;
&lt;th&gt;UTC version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UTC&lt;/td&gt;
&lt;td&gt;庚辰&lt;/td&gt;
&lt;td&gt;庚辰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;America/New_York&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;己卯&lt;/strong&gt; (wrong)&lt;/td&gt;
&lt;td&gt;庚辰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Europe/London&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;己卯&lt;/strong&gt; (wrong)&lt;/td&gt;
&lt;td&gt;庚辰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asia/Seoul&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;己卯&lt;/strong&gt; (wrong)&lt;/td&gt;
&lt;td&gt;庚辰&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;New York and London are wrong because of daylight saving: 15 May is in summer time, 31 January is not, so the gap is one hour short of a whole number of days, and &lt;code&gt;Math.floor&lt;/code&gt; drops a day. In January the same code happens to work, which is why this bug survives testing.&lt;/p&gt;

&lt;p&gt;Seoul is worse. Daylight saving isn't the problem there. In the time zone database, Seoul in 1900 was on local mean time, &lt;strong&gt;UTC+8:27:52&lt;/strong&gt;, not today's +9:00. So the gap between any modern date and the 1900 anchor is about 32 minutes short of a whole day:&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="nx"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;Asia&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nc"&gt;Seoul  &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1990&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;864&lt;/span&gt;&lt;span class="nx"&gt;e5&lt;/span&gt;
&lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="mf"&gt;32975.9777&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Math.floor&lt;/code&gt; turns that into 32975, and &lt;strong&gt;every&lt;/strong&gt; date comes out one pillar early. The machine most likely to run a Korean astrology calculator is the one where the naive code always fails.&lt;/p&gt;

&lt;p&gt;The fix is boring: do calendar math on calendar dates. &lt;code&gt;Date.UTC&lt;/code&gt; has no time zone and no DST, and &lt;code&gt;Math.round&lt;/code&gt; absorbs any leftover floating-point dust.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the day pillar does not handle
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Year and month pillars&lt;/strong&gt; are a different problem. They change at solar terms (the year at Ipchun, around 4 February), not on 1 January, so you need an astronomical table or a good library for those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 23:00 boundary.&lt;/strong&gt; Some schools start the new day at 23:00 (the start of the 子 double hour), others at midnight. My calculator uses midnight on the calendar date and says so on the page. Whichever you pick, document it, because users will compare results across apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Birth time zone.&lt;/strong&gt; Use the calendar date where the person was born. Don't use the date converted to the server's zone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;I put this exact function behind a small free page that shows your day pillar and what the Day Master element traditionally means: &lt;a href="https://noligo.co.kr/en/guide/day-master-calculator/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=daypillar-devto" rel="noopener noreferrer"&gt;Find Your Day Master&lt;/a&gt;. It is entertainment and cultural reference, not prediction.&lt;/p&gt;

&lt;p&gt;The same rule runs inside our Android app Starlit Alley, which builds the full chart (&lt;a href="https://play.google.com/store/apps/details?id=com.noligo.saju_app&amp;amp;referrer=utm_source%3Ddevto%26utm_medium%3Darticle%26utm_campaign%3Ddaypillar-devto" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;). I'm part of the small team that makes it.&lt;/p&gt;

&lt;p&gt;If you've hit the Seoul LMT offset in other date code, I'd like to hear where it showed up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance. Every result in it, including the table, came from running the code above in Node.js.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Catching broken vertical game clips before posting, with row-wise Laplacian sharpness</title>
      <dc:creator>HS Solution</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:20:37 +0000</pubDate>
      <link>https://dev.to/hssolution/catching-broken-vertical-game-clips-before-posting-with-row-wise-laplacian-sharpness-bnc</link>
      <guid>https://dev.to/hssolution/catching-broken-vertical-game-clips-before-posting-with-row-wise-laplacian-sharpness-bnc</guid>
      <description>&lt;p&gt;We are a tiny studio, and we post a lot of 9:16 clips of our games. Last week one of them went out broken: the gameplay sat in the top half, the bottom third was an empty blurred background, a debug overlay was still visible, and the game's name was nowhere on screen.&lt;/p&gt;

&lt;p&gt;Nobody "looked" because everybody assumed someone else had. So we wrote a check that a script runs before any vertical video can be posted. It has no ML model. It uses ffmpeg, numpy and one old trick: &lt;strong&gt;row-wise Laplacian sharpness&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Our vertical template puts the gameplay (landscape) in the middle of a 1080x1920 canvas. Above and below it we put a blurred copy of the same footage, plus two text bands: a hook line on top and the game name below.&lt;/p&gt;

&lt;p&gt;Blurred background is soft. Real gameplay and text are sharp. So if you measure "how sharp is each row of pixels", you get a vertical profile of the frame. From that profile you can read where the real content sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharpness per row
&lt;/h2&gt;

&lt;p&gt;For each sampled frame (1s, 25%, 50%, 75% of the clip), grab a PNG with ffmpeg:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-ss&lt;/span&gt; 12.5 &lt;span class="nt"&gt;-i&lt;/span&gt; clip.mp4 &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 1 f2.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a 4-neighbour Laplacian, averaged across each row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;f2.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;W&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="n"&gt;shape&lt;/span&gt;
&lt;span class="n"&gt;lap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# one number per row
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our footage, blurred background and flat colour rows land around 0.2–1.0. Gameplay rows are well above that. We call a row "sharp" at 2.0 or more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we check
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Empty margins.&lt;/strong&gt; Find the first and last sharp rows (ignoring runs shorter than 2 rows). Everything outside them is "blank". Our good template has about 27.6% blank at the bottom by design. The broken clip had 34–49%. The limit is 31%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the gameplay centred?&lt;/strong&gt; Mask out the two text bands, then join sharp runs with gaps of 60 rows or less (dark game scenes have soft rows inside them). Take the longest block of at least 100 rows. Its centre must be between y=760 and y=1040.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there text in the bands?&lt;/strong&gt; In the hook band and the name band we check two things: at least 2% of pixels are bright (above 190), and the band's average sharpness is at least 3.0. White text on a blurred background passes. An empty band fails.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One trick that mattered: a single frame can be a dark scene with almost no sharp rows. So for the "centred" check we take the &lt;strong&gt;maximum of each row across all four frames&lt;/strong&gt; and judge once. Margins and text bands are still judged per frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate
&lt;/h2&gt;

&lt;p&gt;The check writes a contact sheet with the safe-zone lines drawn on it, and a JSON line with the file's sha256 and the measured values. Our posting command refuses a vertical mp4 unless:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the machine check passed for &lt;strong&gt;that exact sha256&lt;/strong&gt;, and&lt;/li&gt;
&lt;li&gt;a different person (not whoever made the clip) has looked at the sheet and signed off with a note.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second rule is not technical, but it is the one that would have saved us last week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't catch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A debug overlay &lt;em&gt;inside&lt;/em&gt; the gameplay area. We now crop it out when building the clip, but the check can't tell UI from gameplay.&lt;/li&gt;
&lt;li&gt;A sharp but wrong frame (for example a menu screen). That's what the human sign-off is for.&lt;/li&gt;
&lt;li&gt;Thresholds are tuned to our template. If your background isn't blurred, row sharpness won't separate content from margin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It runs in a few seconds per clip, and on last week's broken clip it fails on the bottom margin straight away.&lt;/p&gt;




&lt;p&gt;We make small games and apps in Seoul. The clip that started this was for &lt;a href="https://store.steampowered.com/app/5412510/?utm_source=devto" rel="noopener noreferrer"&gt;Leather Atelier&lt;/a&gt;, a cozy bag-crafting game we're building in Godot.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by our in-house AI assistant from our actual code and measurements, and marked as AI-generated.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
