<?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: Piyak</title>
    <description>The latest articles on DEV Community by Piyak (@piyaklabs).</description>
    <link>https://dev.to/piyaklabs</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%2F3978503%2Ff47fd521-55a0-4412-abb0-827deac144be.png</url>
      <title>DEV Community: Piyak</title>
      <link>https://dev.to/piyaklabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/piyaklabs"/>
    <language>en</language>
    <item>
      <title>Casting your friend group as a K-Pop group without making a database the product</title>
      <dc:creator>Piyak</dc:creator>
      <pubDate>Mon, 13 Jul 2026 06:24:55 +0000</pubDate>
      <link>https://dev.to/piyaklabs/casting-your-friend-group-as-a-k-pop-group-without-making-a-database-the-product-543l</link>
      <guid>https://dev.to/piyaklabs/casting-your-friend-group-as-a-k-pop-group-without-making-a-database-the-product-543l</guid>
      <description>&lt;p&gt;&lt;a href="https://ksaju.piyaklabs.com/crew" rel="noopener noreferrer"&gt;Try the demo: K-Saju Crew&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;For fun only.&lt;/strong&gt; K-Saju is an entertainment project. The K-Pop roles below are a playful interpretation of saju-inspired signals, not personality assessment or advice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A two-person compatibility page can stay stateless with almost no effort. Put both birth dates in a URL, render the result on the server, and the link is the record. No account, no database, no cleanup job.&lt;/p&gt;

&lt;p&gt;That was already a product rule in K-Saju. We do not retain personal inputs. A result is reproducible from its GET parameters.&lt;/p&gt;

&lt;p&gt;Then we built &lt;code&gt;/crew&lt;/code&gt;: “What if your friend group debuted as a K-Pop group?” A creator makes a link, sends it to a group chat, and each friend enters their own birth date. At three to seven members, the app assigns distinct positions, shows pairwise chemistry, and creates a shareable poster.&lt;/p&gt;

&lt;p&gt;The fun part is the casting. The engineering problem is that the social flow needs a temporary shared state. A link cannot accumulate submissions by itself.&lt;/p&gt;

&lt;p&gt;This post is about the decisions behind that feature: where we allowed state, how we made the result durable without retaining a lobby forever, and how we kept the casting explainable instead of treating it as a black-box score.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conflict: a self-service group flow needs somewhere to collect data
&lt;/h2&gt;

&lt;p&gt;There were two clean but incomplete options.&lt;/p&gt;

&lt;p&gt;The first was to keep everything stateless. The creator would enter all members' dates at once, then receive a result URL. It matched our existing architecture, but it defeated the point of sharing a link. The person who starts the group often does not know everyone else's date, and asking them to collect it in a chat creates friction before the feature has started.&lt;/p&gt;

&lt;p&gt;The second was a conventional persistent group object. It would make joining easy, but it would turn a deliberately stateless service into one that keeps user-provided dates indefinitely unless we built retention and deletion policies around it.&lt;/p&gt;

&lt;p&gt;We chose a hybrid instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The lobby is temporary state. It stores only the date of birth, time zone, optional nickname, and optional crew name.&lt;/li&gt;
&lt;li&gt;The lobby expires after 30 days.&lt;/li&gt;
&lt;li&gt;A completed result becomes a stateless permalink. It encodes the group inputs in the URL and can render after the lobby is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction matters. Storage exists only to assemble a group. It is not the source of truth for a shared result.&lt;/p&gt;

&lt;p&gt;The lobby lives in one JSON file per crew. At this scale, a filesystem store was a better fit than adding a database dependency. Creation sweeps expired records; reads delete an expired record before returning it. Writes go through a temporary file and rename so a half-written JSON file is not visible as a crew.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeAtomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CrewRecord&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;crewDir&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fileFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;`.tmp-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;fileFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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 permanent result page is &lt;code&gt;/crew/r&lt;/code&gt;. Its query string contains the member dates, optional per-member nicknames, time zone, and language. The codec enforces the same three-to-seven-member constraint on decode that the collection flow enforces on input. A valid URL is enough to recompute the whole report and its Open Graph image.&lt;/p&gt;

&lt;p&gt;There is an unavoidable privacy trade-off here: the share link carries birth dates and nicknames. We say that directly in the lobby and again on the result page. The design does not claim to hide information that a recipient of the link can inspect. It limits what the server retains after the group has been assembled.&lt;/p&gt;

&lt;p&gt;The creator can also delete a lobby immediately. The authorization key is carried in the URL fragment, not in a query parameter. Browsers do not send fragments in HTTP requests, so the server never receives the key as part of a page request. The delete API receives it only when the creator explicitly uses the management action.&lt;/p&gt;

&lt;p&gt;That is not a substitute for access control in a multi-user system. It is a small, appropriate boundary for an anonymous, short-lived lobby with no accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  A result link should survive the lobby that created it
&lt;/h2&gt;

&lt;p&gt;A 30-day lobby is useful for collection, but it is a poor long-term share artifact. A friend may revisit a result after the TTL. A social preview may be fetched later. A link that quietly becomes a 404 makes the group feature feel broken.&lt;/p&gt;

&lt;p&gt;The solution was to treat the lobby and the result as different resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Build the /crew/r search params for a finished group. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeCrewParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CrewParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ko&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;URLSearchParams&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;sp&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nicks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`n&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;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tz&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="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ko&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lang&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ko&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sp&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 lobby URL identifies a temporary resource. The result URL carries the inputs needed to derive a result. This gives us a useful failure mode: losing a lobby stops future joins, but it does not invalidate results already shared.&lt;/p&gt;

&lt;p&gt;There are costs. URLs can be copied into logs, analytics, chat previews, and browser history. We deliberately keep the encoded payload narrow, place no real names or contact details in it, and tell users what it contains. If the feature later needs edits, access controls, or a larger data model, that would be a reason to revisit the architecture rather than stretching this URL format beyond its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Casting roles with a deterministic engine
&lt;/h2&gt;

&lt;p&gt;“Your group has a main dancer and a producer” is easy copy to generate. The harder question is whether a person can tell why they got that role, and whether refreshing the page changes it.&lt;/p&gt;

&lt;p&gt;We made &lt;code&gt;computeGroupChemistry&lt;/code&gt; a pure deterministic function. It accepts three to seven existing card payloads and returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every &lt;code&gt;i &amp;lt; j&lt;/code&gt; pairwise chemistry result;&lt;/li&gt;
&lt;li&gt;an average pair score and a small five-element coverage bonus;&lt;/li&gt;
&lt;li&gt;the strongest pair and, when distinct, the lowest-scoring “tension” pair;&lt;/li&gt;
&lt;li&gt;a group concept; and&lt;/li&gt;
&lt;li&gt;one unique K-Pop position per member.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The group score is intentionally modest: &lt;code&gt;clamp(58, 99, averageScore + (elementCoverage - 3) * 2)&lt;/code&gt;. The pairwise average does most of the work. Element coverage contributes a small bonus for a group that covers more of the five-element model, but it cannot overpower the underlying pair results. We keep the same friendly floor used by the two-person feature: a low score becomes story tension, not a negative judgment about real friends.&lt;/p&gt;

&lt;p&gt;For positions, each member receives a score for each of eight roles: leader, main vocal, main dancer, visual, rapper, maknae, mood maker, and producer. The signals come from the existing fortune-axis values, element balance, and day-master strength. For example, the leader score favors authority plus a small strength bonus; main vocal favors output plus fire.&lt;/p&gt;

&lt;p&gt;The important implementation detail is that this is a global assignment problem, not eight independent “pick the maximum” calls. Independent picks can give every strong member the same role. We create a member-by-position matrix, sort cells by score, then greedily assign the highest available cell while ensuring that both the member and position are unused.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;member&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;member&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;byMember&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PositionAssignment&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;members&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;usedPositions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;assigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;assigned&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;members&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&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="nx"&gt;byMember&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;member&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;usedPositions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;byMember&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;member&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="na"&gt;positionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;POSITION_ORDER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;usedPositions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;assigned&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tie-breaks are explicit: position-table order, then member index. This looks unglamorous, but it is what makes a shared result stable. Given the same inputs, the result and the explanation line are the same.&lt;/p&gt;

&lt;p&gt;Each cell also records its highest-contributing signal. The UI can therefore say that someone was cast as leader because their authority signal led the group, rather than presenting a score with no provenance. In an entertainment feature, “explainable” does not mean scientifically validated. It means the product does not pretend its output appeared by magic.&lt;/p&gt;

&lt;p&gt;We also avoided making the group label depend on raw counts, because a seven-person crew has 21 pairs while a three-person crew has three. Group concepts use shares of pair types, with ordered thresholds. We tuned those thresholds against 300 simulated groups so the six concepts did not collapse into one default label. The simulation was not a claim about users; it was a quick distribution check for product copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency and abuse were part of the product flow
&lt;/h2&gt;

&lt;p&gt;A group-chat link creates a predictable race: several people tap Join at the same time. Joining is a read-modify-write operation, so naive file access can lose a member even if every request returns success.&lt;/p&gt;

&lt;p&gt;The store serializes joins per crew ID in the Node process. It also rejects an eighth member, malformed dates, and an accidental duplicate made of the same date plus nickname. Twins with the same date remain possible.&lt;/p&gt;

&lt;p&gt;We added per-IP daily caps to creation and joining routes as well. Rate limits do not make a public form abuse-proof, but they prevent the obvious path from turning an inexpensive filesystem feature into a pile of junk records.&lt;/p&gt;

&lt;p&gt;This was another reason to name the deployment assumption in code: in-memory serialization is sufficient for our single Node process and filesystem store. It is not a distributed lock. If the service moves to multiple instances, the storage and locking design must move with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering a group poster with Satori
&lt;/h2&gt;

&lt;p&gt;The permanent URL also powers the social preview. The OG route rebuilds the charts and group result directly from the query string, then renders a 1200×630 poster with three to seven chick characters and their position labels.&lt;/p&gt;

&lt;p&gt;Satori is excellent for programmatic social images, but it is not a browser. Its layout and font support reward simple flexbox layouts and punish assumptions that happen to work in CSS on a normal page. We avoided emoji-dependent graphics, used image assets for the chicks, capped labels, and set &lt;code&gt;flexShrink: 0&lt;/code&gt; on lineup items. Character sizes step down as the crew grows so seven members still fit inside the 1100-pixel content width.&lt;/p&gt;

&lt;p&gt;That route is another practical benefit of stateless results: an OG crawler does not need a session, a cookie, or a live lobby record. It only needs the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep, and what I would change later
&lt;/h2&gt;

&lt;p&gt;The hybrid model gave us the social interaction we wanted without turning short-lived collection data into a permanent dataset. It also made the share artifact stronger than the temporary workflow that created it.&lt;/p&gt;

&lt;p&gt;I would keep the separation between “assemble a group” and “render a result.” It makes retention policy, deletion behavior, and caching much easier to reason about.&lt;/p&gt;

&lt;p&gt;I would not force this implementation to become a general collaboration system. Editing members, extending storage, private groups, and multi-instance deployment all change the problem. The next version would need a real persistence and authorization design.&lt;/p&gt;

&lt;p&gt;For this feature, a narrow temporary lobby plus a reproducible result URL was enough. It let friends fill in their own data, made the K-Pop casting feel consistent, and kept the server’s memory of the group deliberately short.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ksaju.piyaklabs.com/crew" rel="noopener noreferrer"&gt;Try K-Saju Crew&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>Designing K-Saju: turning a traditional birth-chart system into a lightweight K-culture web product</title>
      <dc:creator>Piyak</dc:creator>
      <pubDate>Tue, 07 Jul 2026 06:11:32 +0000</pubDate>
      <link>https://dev.to/piyaklabs/designing-k-saju-turning-a-traditional-birth-chart-system-into-a-lightweight-k-culture-web-product-hp8</link>
      <guid>https://dev.to/piyaklabs/designing-k-saju-turning-a-traditional-birth-chart-system-into-a-lightweight-k-culture-web-product-hp8</guid>
      <description>&lt;h1&gt;
  
  
  Designing K-Saju: turning a traditional birth-chart system into a lightweight K-culture web product
&lt;/h1&gt;

&lt;p&gt;I recently shipped the first public version of &lt;strong&gt;K-Saju&lt;/strong&gt;, a small web project that reimagines Korean Saju / Four Pillars as a K-culture character card and friend chemistry experience.&lt;/p&gt;

&lt;p&gt;Live: &lt;a href="https://ksaju.piyaklabs.com?ref=devto" rel="noopener noreferrer"&gt;https://ksaju.piyaklabs.com?ref=devto&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Personal build log, in Korean: &lt;a href="https://log.piyaklabs.com/posts/dev/k-saju-project-open/" rel="noopener noreferrer"&gt;https://log.piyaklabs.com/posts/dev/k-saju-project-open/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The product pitch is intentionally simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hatch your K-drama / K-pop style character in about 10 seconds, then check your chemistry with a friend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post is not a deep implementation guide yet. Instead, it is a product/technical design write-up: how I framed an old cultural system for global users, what I chose to hide, what I chose to simplify, and why the first version focuses on birth date, share cards, and 1:1 chemistry.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: Saju is interesting, but the default UX is heavy
&lt;/h2&gt;

&lt;p&gt;In Korea, Saju is familiar enough that many people have encountered it at least once: New Year readings, naming, relationship compatibility, or just casual curiosity.&lt;/p&gt;

&lt;p&gt;For a global audience, though, the default packaging has problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The terminology is unfamiliar.&lt;/li&gt;
&lt;li&gt;It can sound deterministic or fatalistic.&lt;/li&gt;
&lt;li&gt;Traditional explanations can be dense.&lt;/li&gt;
&lt;li&gt;Some users may connect it with serious fortune-telling rather than playful self-discovery.&lt;/li&gt;
&lt;li&gt;Many English-speaking users do not know their exact birth time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the core product question became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How can I preserve the structure of Saju without making the first user experience feel like a heavy fortune report?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My answer for version 1.0 was to treat Saju as the underlying engine and K-culture archetypes as the user-facing layer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Product frame: not “your destiny”, but “your character card”
&lt;/h2&gt;

&lt;p&gt;A lot of personality products work because they give users language they can share:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“I am an INFJ.”&lt;/li&gt;
&lt;li&gt;“I am a Leo.”&lt;/li&gt;
&lt;li&gt;“This is my role in the group.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Saju has enough structure to support that kind of experience, but the raw vocabulary is not friendly for a first session. So K-Saju leads with concepts such as:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Traditional idea&lt;/th&gt;
&lt;th&gt;User-facing language&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Saju / Four Pillars&lt;/td&gt;
&lt;td&gt;K-Saju Birth Chart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five Elements&lt;/td&gt;
&lt;td&gt;Element Balance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day Master / core stem&lt;/td&gt;
&lt;td&gt;Core Energy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compatibility&lt;/td&gt;
&lt;td&gt;Chemistry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Harmony&lt;/td&gt;
&lt;td&gt;Natural Bond&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clash&lt;/td&gt;
&lt;td&gt;Tension Point&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not meant to erase the origin or tradition. It is an onboarding layer.&lt;/p&gt;

&lt;p&gt;The cultural note is also important: K-Saju is inspired by modern Korean Saju culture, but Saju belongs to the broader East Asian Four Pillars tradition. The product should never claim that Korea uniquely invented the system.&lt;/p&gt;
&lt;h2&gt;
  
  
  Guardrail: avoid deterministic claims
&lt;/h2&gt;

&lt;p&gt;One design rule I keep coming back to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The product should feel like entertainment and self-reflection, not a fixed prediction about someone’s life.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That affects the copy everywhere.&lt;/p&gt;

&lt;p&gt;Prefer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“You may tend to…”&lt;/li&gt;
&lt;li&gt;“This pattern can show up as…”&lt;/li&gt;
&lt;li&gt;“Your growth point might be…”&lt;/li&gt;
&lt;li&gt;“This chemistry works best when…”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“You will definitely…”&lt;/li&gt;
&lt;li&gt;“This person is your soulmate.”&lt;/li&gt;
&lt;li&gt;“You should break up.”&lt;/li&gt;
&lt;li&gt;“Your future is…”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is partly a safety issue, but it is also a product issue. Global users who enjoy astrology apps, MBTI, tarot, or character tests often want recognition and conversation starters. They do not necessarily want a heavy fatalistic reading.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why birth date first?
&lt;/h2&gt;

&lt;p&gt;Traditional Four Pillars uses year, month, day, and hour. The hour pillar matters, but it creates a very practical funnel problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Many global users do not know their birth time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If birth time is required, the product loses a large portion of curious users before they see any value.&lt;/p&gt;

&lt;p&gt;So the first version is designed around a birth-date-first experience:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask for enough information to generate a lightweight card.&lt;/li&gt;
&lt;li&gt;Make the free result feel complete.&lt;/li&gt;
&lt;li&gt;Treat birth time as an optional deeper layer later, not a blocker.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That meant the flagship result had to work from year/month/day and element balance, while leaving room for premium or advanced readings later.&lt;/p&gt;
&lt;h2&gt;
  
  
  System shape
&lt;/h2&gt;

&lt;p&gt;The product architecture is intentionally split into layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Birth input
  -&amp;gt; deterministic Saju / calendar calculation
  -&amp;gt; structured chart features
  -&amp;gt; rule-based interpretation and archetype mapping
  -&amp;gt; friendly K-culture copy
  -&amp;gt; result card / OG image / share flow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important constraint is that an LLM should not calculate the chart.&lt;/p&gt;

&lt;p&gt;I like LLMs for rewriting, summarizing, and tone variation, but the birth-chart calculation should be deterministic and testable. If the calculation is wrong, the entire product loses credibility.&lt;/p&gt;

&lt;p&gt;In the current repo, the engine is a separate package from the web app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;engine/  -&amp;gt; deterministic calculation and card data
web/     -&amp;gt; Next.js app, UI, result pages, share/OG routes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The web app is built with Next.js and React, while the engine is a small TypeScript package. Keeping the engine separate makes it easier to test the calculation logic independently from the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping chart features to characters
&lt;/h2&gt;

&lt;p&gt;The fun part is the interpretation layer.&lt;/p&gt;

&lt;p&gt;Instead of showing traditional terms first, K-Saju maps structured chart features into shareable archetypes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;K-Saju type&lt;/li&gt;
&lt;li&gt;Element Balance&lt;/li&gt;
&lt;li&gt;Core Energy&lt;/li&gt;
&lt;li&gt;K-pop-style group role&lt;/li&gt;
&lt;li&gt;K-drama-style character role&lt;/li&gt;
&lt;li&gt;relationship style&lt;/li&gt;
&lt;li&gt;friend chemistry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The archetypes must feel explainable, not random.&lt;/p&gt;

&lt;p&gt;For example, if a result has strong expressive/fire-like energy, a user-facing role might feel closer to a “Stage Spark” than a quiet strategist. If a result is more grounding and stabilizing, it might map toward a “Team Anchor” or warm support role.&lt;/p&gt;

&lt;p&gt;The exact mapping rules can evolve, but the product principle is fixed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The result should pass the user’s “that sounds like me” gut check.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the card feels random, no one will share it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pop culture without IP trouble
&lt;/h2&gt;

&lt;p&gt;Because the product uses K-pop and K-drama language, there is an obvious trap: referencing real idols, real groups, real shows, or brand names.&lt;/p&gt;

&lt;p&gt;I avoid that.&lt;/p&gt;

&lt;p&gt;K-Saju uses generic archetypes, not real people or fictional characters. Examples of the style:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stage Spark&lt;/li&gt;
&lt;li&gt;Team Anchor&lt;/li&gt;
&lt;li&gt;Mood Reader&lt;/li&gt;
&lt;li&gt;Detail Ace&lt;/li&gt;
&lt;li&gt;Bold Main Character&lt;/li&gt;
&lt;li&gt;Mysterious Second Lead&lt;/li&gt;
&lt;li&gt;Warm Best Friend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the product legally safer and makes the archetypes more reusable. It also helps the service feel like its own world rather than a derivative fan quiz.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing for sharing: cards before reports
&lt;/h2&gt;

&lt;p&gt;For this kind of product, the free result card matters more than the paid report.&lt;/p&gt;

&lt;p&gt;A long private report might be useful, but it does not naturally travel. A card does.&lt;/p&gt;

&lt;p&gt;So version 1.0 focuses on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a mobile-first result page&lt;/li&gt;
&lt;li&gt;a visually distinct card&lt;/li&gt;
&lt;li&gt;a shareable poster / OG image&lt;/li&gt;
&lt;li&gt;a result that can be sent to a friend&lt;/li&gt;
&lt;li&gt;a direct path into 1:1 chemistry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ideal loop is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I get my card
  -&amp;gt; I share it
  -&amp;gt; a friend tries theirs
  -&amp;gt; we compare chemistry
  -&amp;gt; the friend shares again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is why the first product wedge is not “build the most complete Saju report.” It is “make the first card interesting enough to share.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 1:1 chemistry is in v1
&lt;/h2&gt;

&lt;p&gt;A solo personality card is fun, but relationship content is more viral.&lt;/p&gt;

&lt;p&gt;People do not just want to know “What am I?” They also want to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What are we together?&lt;/li&gt;
&lt;li&gt;Why do we click?&lt;/li&gt;
&lt;li&gt;Where is the tension?&lt;/li&gt;
&lt;li&gt;Are we co-stars, rivals, or a chaotic duo?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So K-Saju 1.0 includes both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;My K-Saju&lt;/strong&gt; — the personal character card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Friend chemistry&lt;/strong&gt; — a 1:1 comparison flow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Again, the language stays soft. The chemistry result should be a conversation starter, not a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mascot and visual direction
&lt;/h2&gt;

&lt;p&gt;Piyaklabs has a yellow chick mascot. For K-Saju, I decided to use the mascot as the brand anchor.&lt;/p&gt;

&lt;p&gt;That solved a tone problem.&lt;/p&gt;

&lt;p&gt;Saju can look mystical, serious, or heavy. A small chick character makes the product feel warmer and more approachable. The user is not receiving a scary fortune report; they are hatching a K-style character card.&lt;/p&gt;

&lt;p&gt;For the result images, I prefer pre-made static assets mapped to result types instead of real-time per-user image generation.&lt;/p&gt;

&lt;p&gt;Reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;faster page loads&lt;/li&gt;
&lt;li&gt;more consistent art direction&lt;/li&gt;
&lt;li&gt;easier QA&lt;/li&gt;
&lt;li&gt;fewer unpredictable image-generation failures&lt;/li&gt;
&lt;li&gt;stronger brand identity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current direction is to vary outfit, background, props, expression, and mood by result type while keeping the mascot recognizable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped in 1.0
&lt;/h2&gt;

&lt;p&gt;The first public version includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;personal K-Saju result flow&lt;/li&gt;
&lt;li&gt;birth-date-based result generation&lt;/li&gt;
&lt;li&gt;element / role / archetype style result copy&lt;/li&gt;
&lt;li&gt;share-oriented result card&lt;/li&gt;
&lt;li&gt;1:1 friend chemistry flow&lt;/li&gt;
&lt;li&gt;poster/OG image direction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next step is premium functionality: deeper readings, richer relationship reports, and more detailed result layers.&lt;/p&gt;

&lt;p&gt;I am intentionally not starting with a subscription or a massive app. The current goal is to validate whether users:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;complete the birth input,&lt;/li&gt;
&lt;li&gt;like the card,&lt;/li&gt;
&lt;li&gt;share it,&lt;/li&gt;
&lt;li&gt;invite a friend into the chemistry flow,&lt;/li&gt;
&lt;li&gt;show interest in a deeper report.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Lessons so far
&lt;/h2&gt;

&lt;p&gt;A few early product lessons from building this:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Translation is not localization
&lt;/h3&gt;

&lt;p&gt;Translating Saju terms directly would make the UI technically accurate but emotionally distant. The product needs to translate the experience, not just the words.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The hard part is not only calculation
&lt;/h3&gt;

&lt;p&gt;The calculation engine matters, but the product succeeds or fails on interpretation, tone, and shareability.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Optional inputs can be more powerful than complete inputs
&lt;/h3&gt;

&lt;p&gt;If requiring birth time kills the funnel, it is better to make the first experience work without it and reserve deeper precision for later.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Cultural products need guardrails
&lt;/h3&gt;

&lt;p&gt;The service needs to be clear about origin, avoid deterministic advice, and avoid real pop-culture IP references.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A small visual system can carry a lot
&lt;/h3&gt;

&lt;p&gt;The mascot is not decoration. It changes the emotional weight of the product.&lt;/p&gt;

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

&lt;p&gt;You can try the current version here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ksaju.piyaklabs.com?ref=devto" rel="noopener noreferrer"&gt;https://ksaju.piyaklabs.com?ref=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Korean personal build log is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://log.piyaklabs.com/posts/dev/k-saju-project-open/" rel="noopener noreferrer"&gt;https://log.piyaklabs.com/posts/dev/k-saju-project-open/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I will probably write a more technical follow-up later about the engine, result mapping, and OG image generation pipeline.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>product</category>
    </item>
    <item>
      <title>Comments on a static blog — no backend, no login, all Cloudflare</title>
      <dc:creator>Piyak</dc:creator>
      <pubDate>Tue, 23 Jun 2026 04:19:07 +0000</pubDate>
      <link>https://dev.to/piyaklabs/comments-on-a-static-blog-no-backend-no-login-all-cloudflare-18b</link>
      <guid>https://dev.to/piyaklabs/comments-on-a-static-blog-no-backend-no-login-all-cloudflare-18b</guid>
      <description>&lt;h1&gt;
  
  
  Comments on a static blog — no backend, no login, all Cloudflare
&lt;/h1&gt;

&lt;p&gt;I run a small Astro blog on Cloudflare Pages. It mixes developer write-ups with personal, everyday posts, so adding comments came with one hard constraint: &lt;strong&gt;no login wall.&lt;/strong&gt; A GitHub-login widget like Giscus or Utterances would shut out every non-developer reader.&lt;/p&gt;

&lt;p&gt;That ruled out the easy paths. Disqus is heavy and tracker-laden. Waline is genuinely good, but it wants a backend + database running &lt;em&gt;outside&lt;/em&gt; Cloudflare — one more thing to operate. The blog already lives on Cloudflare Pages, so the goal became: &lt;strong&gt;keep comments inside the same stack. No login, no spam, no separate server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what I shipped — comments, likes, and moderation — entirely on Pages + D1 + Turnstile, with a Telegram bot as the moderation UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Static Astro (dist) ── Cloudflare Pages
   ├─ /api/comments  (Pages Function) → Turnstile verify → D1 insert (approved=0) → Telegram notify
   ├─ /api/likes     (Pages Function) → D1 counter (POST +1 / DELETE -1)
   └─ /api/telegram/webhook → approve / reject / delete (secret_token auth)
D1: comments, likes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site is statically built. Anything dynamic is just a Pages Function hitting one D1 database. There is no origin server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comments: no login, pre-moderated
&lt;/h2&gt;

&lt;p&gt;Spam protection without a login is &lt;strong&gt;Cloudflare Turnstile&lt;/strong&gt; — a free, privacy-friendly CAPTCHA. The browser solves the challenge, and the Function verifies the token server-side before it touches the database.&lt;/p&gt;

&lt;p&gt;Every comment is stored with &lt;code&gt;approved = 0&lt;/code&gt; and &lt;strong&gt;is not shown until I approve it.&lt;/strong&gt; For a brand-new blog, that means it can never be papered over with spam — nothing is public until I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moderation UI is a Telegram bot
&lt;/h2&gt;

&lt;p&gt;I didn't build an admin page. When a comment lands, the bot DMs me with inline buttons — &lt;code&gt;[✅ Approve] [❌ Reject]&lt;/code&gt;. Approving flips &lt;code&gt;approved = 1&lt;/code&gt;. The approved message then keeps a &lt;code&gt;🗑 Delete&lt;/code&gt; button, so I can remove an already-published comment from the same chat later.&lt;/p&gt;

&lt;p&gt;The webhook is authenticated. Telegram sends an &lt;code&gt;X-Telegram-Bot-Api-Secret-Token&lt;/code&gt; header (you set it via &lt;code&gt;secret_token&lt;/code&gt; on &lt;code&gt;setWebhook&lt;/code&gt;), and the Function rejects anything that doesn't match:&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;// setWebhook (placeholders — never commit real values)&lt;/span&gt;
&lt;span class="c1"&gt;// POST https://api.telegram.org/bot&amp;lt;YOUR_BOT_TOKEN&amp;gt;/setWebhook&lt;/span&gt;
&lt;span class="c1"&gt;//   url=https://log.piyaklabs.com/api/telegram/webhook&lt;/span&gt;
&lt;span class="c1"&gt;//   secret_token=&amp;lt;YOUR_WEBHOOK_SECRET&amp;gt;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELEGRAM_WEBHOOK_SECRET&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;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Telegram-Bot-Api-Secret-Token&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;got&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELEGRAM_WEBHOOK_SECRET&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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;h2&gt;
  
  
  Likes: a counter on a static site (and the bug I earned)
&lt;/h2&gt;

&lt;p&gt;Likes are a D1 counter plus &lt;code&gt;localStorage&lt;/code&gt; to remember "you liked this." First version: like → &lt;code&gt;POST&lt;/code&gt; (+1), unlike → only clear &lt;code&gt;localStorage&lt;/code&gt;. The &lt;strong&gt;bug&lt;/strong&gt;: refresh, unlike locally, like again, and the server count climbs forever — because the server never saw the unlike.&lt;/p&gt;

&lt;p&gt;The fix is to make it symmetric: like = &lt;code&gt;POST&lt;/code&gt; (+1), unlike = &lt;code&gt;DELETE&lt;/code&gt; (−1, floored at 0). No login means there's no perfect one-person-one-vote, but for a personal blog this is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small stuff
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The comment form tells readers up front that comments appear after approval.&lt;/li&gt;
&lt;li&gt;Private posts get neither comments nor likes (reusing an existing &lt;code&gt;isPrivate&lt;/code&gt; flag).&lt;/li&gt;
&lt;li&gt;I also dropped in Cloudflare Web Analytics — cookieless, no consent banner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I like this shape
&lt;/h2&gt;

&lt;p&gt;Pre-moderation plus Telegram-as-admin means I run zero extra infrastructure and moderate from my phone with one tap. Cost is $0, the stack is one thing, and there's no backend to keep alive.&lt;/p&gt;

&lt;p&gt;If you're on Cloudflare Pages and want comments that feel self-hosted without running a server, this pattern is worth copying.&lt;/p&gt;

&lt;p&gt;See it live at the bottom of any post: &lt;strong&gt;&lt;a href="https://log.piyaklabs.com" rel="noopener noreferrer"&gt;https://log.piyaklabs.com&lt;/a&gt;&lt;/strong&gt; — leave a comment, or borrow the pattern for your own blog.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>astro</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Routing around Google Maps in Korea: Naver &amp; Kakao deep links, weird coordinates, and iOS clipboard</title>
      <dc:creator>Piyak</dc:creator>
      <pubDate>Thu, 18 Jun 2026 04:50:22 +0000</pubDate>
      <link>https://dev.to/piyaklabs/routing-around-google-maps-in-korea-naver-kakao-deep-links-weird-coordinates-and-ios-clipboard-25mf</link>
      <guid>https://dev.to/piyaklabs/routing-around-google-maps-in-korea-naver-kakao-deep-links-weird-coordinates-and-ios-clipboard-25mf</guid>
      <description>&lt;p&gt;If you've traveled to Korea, you've hit this wall: &lt;strong&gt;Google Maps can't give you walking or transit directions here.&lt;/strong&gt;&lt;br&gt;
Map-data export is restricted, so locals use &lt;strong&gt;Naver Map&lt;/strong&gt; or &lt;strong&gt;KakaoMap&lt;/strong&gt; instead. The usual workaround for visitors is&lt;br&gt;
painful — copy a place's Korean name from Google, paste it into Naver, repeat for every stop.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://kmap.piyaklabs.com" rel="noopener noreferrer"&gt;&lt;strong&gt;K-Map Router&lt;/strong&gt;&lt;/a&gt;: paste a Google Maps link → it opens that place (and the route) in&lt;br&gt;
Naver or Kakao. Free, no sign-up, nothing stored. Code is here:&lt;br&gt;
&lt;a href="https://github.com/piyaklabs/k-map-router" rel="noopener noreferrer"&gt;github.com/piyaklabs/k-map-router&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post isn't a "look how hard I worked" story — honestly, I shipped it fast with heavy AI pair-programming (Claude Code).&lt;br&gt;
It's a &lt;strong&gt;field guide to the stuff that's genuinely hard to find documented&lt;/strong&gt;: how Korean map deep links and coordinates&lt;br&gt;
actually work. If you ever build something in this space, I hope this saves you a few days.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture, in one breath
&lt;/h2&gt;

&lt;p&gt;A single &lt;strong&gt;Cloudflare Worker&lt;/strong&gt; serves both the React SPA (static assets) and the &lt;code&gt;POST /api/resolve&lt;/code&gt; endpoint — same origin,&lt;br&gt;
free tier, &lt;strong&gt;zero runtime dependencies&lt;/strong&gt;. Coordinate resolution is server-side (the browser → Google is blocked by CORS) and&lt;br&gt;
is nothing but &lt;code&gt;fetch&lt;/code&gt; + regex + a little decoding. No DB, stateless.&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part #1: coordinates live in &lt;em&gt;different&lt;/em&gt; formats per URL
&lt;/h2&gt;

&lt;p&gt;You can't just regex one pattern. A Google Maps URL (after following redirects) hides the coordinates in one of several&lt;br&gt;
shapes, and you have to try them in priority order:&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;// 1) place pin — most authoritative&lt;/span&gt;
  &lt;span class="c1"&gt;//    ...!3d{lat}!4d{lng}&lt;/span&gt;
  &lt;span class="c1"&gt;// 2) directions waypoint — ⚠️ REVERSED: !1d{lng}!2d{lat}&lt;/span&gt;
  &lt;span class="c1"&gt;//    multiple pairs =&amp;gt; last = destination, first = origin&lt;/span&gt;
  &lt;span class="c1"&gt;// 3) viewport center — /@{lat},{lng},17z&lt;/span&gt;
  &lt;span class="c1"&gt;// 4) ?query= / &amp;amp;destination= / &amp;amp;daddr=&lt;/span&gt;
  &lt;span class="c1"&gt;// 5) ?ll= / &amp;amp;sll=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that bit me hardest: &lt;strong&gt;&lt;code&gt;/dir/&lt;/code&gt; directions URLs store &lt;code&gt;!1d{longitude}!2d{latitude}&lt;/code&gt; — longitude first.&lt;/strong&gt; Read it as&lt;br&gt;
&lt;code&gt;(lat, lng)&lt;/code&gt; and you'll happily return a point that's in the ocean. And when there are multiple pairs, the &lt;em&gt;last&lt;/em&gt; pair is the&lt;br&gt;
destination, the &lt;em&gt;first&lt;/em&gt; is the start point (which is how the tool can preserve A→B routes).&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part #2: mobile "Copy link" hides coordinates in a protobuf
&lt;/h2&gt;

&lt;p&gt;Links shared from the &lt;strong&gt;Google Maps mobile app&lt;/strong&gt; (the ones with &lt;code&gt;?g_st=...&lt;/code&gt;) are special. They resolve to something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  .../maps?saddr=Seoul+Station&amp;amp;daddr=Gyeongbokgung&amp;amp;geocode=FWoPPQId...;FWFrPQIdEYSRBy...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are &lt;strong&gt;no plaintext coordinates anywhere&lt;/strong&gt; — they're base64url-encoded in the &lt;code&gt;geocode=&lt;/code&gt; param, as a tiny protobuf. Each&lt;br&gt;
&lt;code&gt;;&lt;/code&gt;-separated entry encodes one endpoint:&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;// 0x15 = field 2, fixed32 (little-endian) = lat * 1e6&lt;/span&gt;
  &lt;span class="c1"&gt;// 0x1D = field 3, fixed32 (little-endian) = lng * 1e6&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeGeocodeEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&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;bin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&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="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&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;0&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;4&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&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="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;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mh"&gt;0x15&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mh"&gt;0x1d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// LE&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mh"&gt;0x7fffffff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mh"&gt;0x100000000&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="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&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;4&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="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;Verified against 경복궁 (Gyeongbokgung): &lt;code&gt;FWFrPQIdEYSRBy...&lt;/code&gt; → &lt;code&gt;37.579617, 126.977041&lt;/code&gt;. ✅&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard part #3: the deep link specs (and their gotchas)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Naver&lt;/strong&gt; (primary — best transit + English):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  nmap://route/public?dlat={lat}&amp;amp;dlng={lng}&amp;amp;dname={enc}&amp;amp;appname={APPNAME}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;appname&lt;/code&gt; is &lt;strong&gt;required&lt;/strong&gt; (silently fails without it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dname&lt;/code&gt; is optional — omit it and Naver shows the real address. Don't send a literal &lt;code&gt;Destination&lt;/code&gt; placeholder.&lt;/li&gt;
&lt;li&gt;Modes are different action paths: &lt;code&gt;route/walk&lt;/code&gt;, &lt;code&gt;route/car&lt;/code&gt;, &lt;code&gt;route/public&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Kakao&lt;/strong&gt; (secondary):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  kakaomap://route?ep={lat},{lng}&amp;amp;by=publictransit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Modes: &lt;code&gt;by=foot | car | publictransit&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Android:&lt;/strong&gt; custom schemes are flaky from Chrome. Use an &lt;code&gt;intent://&lt;/code&gt; URL with a built-in store fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  intent://route/public?...#Intent;scheme=nmap;package=com.nhn.android.nmap;S.browser_fallback_url=...;end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Hard part #4: Kakao's web URL uses a coordinate system from another dimension
&lt;/h2&gt;

&lt;p&gt;For desktop fallback, Kakao's legacy &lt;code&gt;link/to&lt;/code&gt; API can't take a start point. But its redirect target can — if you feed it&lt;br&gt;
Kakao's internal &lt;strong&gt;WCongnamul&lt;/strong&gt; coordinates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;  https://map.kakao.com/?map_type=TYPE_MAP&amp;amp;target=traffic&amp;amp;rt={sx},{sy},{ex},{ey}&amp;amp;rt1={from}&amp;amp;rt2={to}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WCongnamul turned out to be &lt;strong&gt;EPSG:5181 (a GRS80 Transverse Mercator) scaled ×2.5&lt;/strong&gt;. I implemented the projection by hand and&lt;br&gt;
it matched Kakao's own conversion to the integer for every test point. (Also: never put a comma in the &lt;code&gt;rt1/rt2&lt;/code&gt; label — it&lt;br&gt;
breaks the parser and silently drops the destination.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part #5: the iOS clipboard "paste" button that wouldn't paste
&lt;/h2&gt;

&lt;p&gt;The "Paste from clipboard" button worked everywhere except iOS. Two reasons, both subtle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Google Maps "Copy link" puts the URL on the clipboard as &lt;strong&gt;&lt;code&gt;text/uri-list&lt;/code&gt; only&lt;/strong&gt; — no &lt;code&gt;text/plain&lt;/code&gt;. So
&lt;code&gt;navigator.clipboard.readText()&lt;/code&gt; returns an empty string.&lt;/li&gt;
&lt;li&gt;iOS WebKit expires the user activation after your first &lt;code&gt;await&lt;/code&gt;. So a &lt;code&gt;readText()&lt;/code&gt; → fall back to &lt;code&gt;read()&lt;/code&gt; chain &lt;strong&gt;always
fails on the second call&lt;/strong&gt; with &lt;code&gt;NotAllowedError&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is to make &lt;strong&gt;exactly one&lt;/strong&gt; clipboard call inside the gesture, then read the type off the already-resolved&lt;br&gt;
&lt;code&gt;ClipboardItem&lt;/code&gt; (those &lt;code&gt;getType&lt;/code&gt; calls reuse the granted permission):&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// one call, in the gesture&lt;/span&gt;
  &lt;span class="k"&gt;for &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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &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;type&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/uri-list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;text&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="c1"&gt;// uri-list: first non-comment line is the URL&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&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 iOS "Paste" permission bubble itself is unavoidable — it's OS-enforced for any programmatic clipboard read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: respect the mode the user already picked
&lt;/h2&gt;

&lt;p&gt;Google encodes the travel mode in the link (&lt;code&gt;travelmode=driving&lt;/code&gt;, &lt;code&gt;dirflg=d&lt;/code&gt;, or &lt;code&gt;!3e0&lt;/code&gt;). Reading it means a shared &lt;em&gt;driving&lt;/em&gt;&lt;br&gt;
route opens directly in driving directions in Naver/Kakao — "plan in Google Maps, navigate in Korea," unchanged.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it / take it
&lt;/h2&gt;

&lt;p&gt;&lt;iframe class="tweet-embed" id="tweet-2067124302403772609-944" src="https://platform.twitter.com/embed/Tweet.html?id=2067124302403772609"&gt;
&lt;/iframe&gt;

  // Detect dark theme
  var iframe = document.getElementById('tweet-2067124302403772609-944');
  if (document.body.className.includes('dark-theme')) {
    iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2067124302403772609&amp;amp;theme=dark"
  }



&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;strong&gt;&lt;a href="https://kmap.piyaklabs.com" rel="noopener noreferrer"&gt;kmap.piyaklabs.com&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Code (MIT-ish, stateless, zero deps): &lt;strong&gt;&lt;a href="https://github.com/piyaklabs/k-map-router" rel="noopener noreferrer"&gt;github.com/piyaklabs/k-map-router&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything that bridges Google Maps and Korean map apps, steal the deep-link and coordinate logic — that's&lt;br&gt;
exactly why it's public. Questions welcome. 🐣&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
