<?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: Jaehoon Lee</title>
    <description>The latest articles on DEV Community by Jaehoon Lee (@gaptripdev).</description>
    <link>https://dev.to/gaptripdev</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%2F4053574%2F7aae57a2-4f00-4cc8-a6e2-78cac50503a2.png</url>
      <title>DEV Community: Jaehoon Lee</title>
      <link>https://dev.to/gaptripdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gaptripdev"/>
    <language>en</language>
    <item>
      <title>I positioned my Android app around the wrong user moment — here’s what I changed</title>
      <dc:creator>Jaehoon Lee</dc:creator>
      <pubDate>Mon, 10 Aug 2026 03:12:06 +0000</pubDate>
      <link>https://dev.to/gaptripdev/i-positioned-my-android-app-around-the-wrong-user-moment-heres-what-i-changed-2d1a</link>
      <guid>https://dev.to/gaptripdev/i-positioned-my-android-app-around-the-wrong-user-moment-heres-what-i-changed-2d1a</guid>
      <description>&lt;p&gt;I recently noticed that the screenshots and short-form posts for my Android app were explaining a different product from the one I had actually built.&lt;/p&gt;

&lt;p&gt;The app's real loop is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user has already decided on one place.&lt;/li&gt;
&lt;li&gt;They search for that real place.&lt;/li&gt;
&lt;li&gt;The app shows a playful daily “fit between me and this place” score and a short reading.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is not a place recommender, route planner, or two-location comparison tool. But my early store assets kept showing two places side by side because comparison looked more dramatic in a screenshot.&lt;/p&gt;

&lt;p&gt;That decision made the product harder to understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The positioning bug
&lt;/h2&gt;

&lt;p&gt;I had treated marketing assets as a feature inventory. Search, scores, history, and multiple screens all appeared at once.&lt;/p&gt;

&lt;p&gt;The result was technically accurate in pieces, but wrong as a story. A viewer could reasonably think:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Is this choosing a destination for me?”&lt;/li&gt;
&lt;li&gt;“Do I need to compare two cafés?”&lt;/li&gt;
&lt;li&gt;“Is this a navigation app with a score?”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those matched the first useful moment in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correction
&lt;/h2&gt;

&lt;p&gt;I rewrote the visual sequence around one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I already chose this place. How does it feel for me today?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That changed the order of every asset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start with one recognizable place.&lt;/li&gt;
&lt;li&gt;Show the user searching that exact place.&lt;/li&gt;
&lt;li&gt;Show the actual result screen long enough to read it.&lt;/li&gt;
&lt;li&gt;Explain the score as a light daily reflection, not a prediction.&lt;/li&gt;
&lt;li&gt;End with one action: search the place you already plan to visit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also removed decorative score badges and fake comparison cards from promotional visuals. If a screen did not exist in the app, it no longer appeared in the ad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four checks I now use for store screenshots
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Test the first screenshot at thumbnail size
&lt;/h3&gt;

&lt;p&gt;If the main sentence cannot be understood before opening the listing, the screenshot is too busy.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Describe the user's moment, not the feature
&lt;/h3&gt;

&lt;p&gt;“Search places” is a feature. “Check today’s feeling with the place you already chose” describes why the user opens the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keep one noun consistent
&lt;/h3&gt;

&lt;p&gt;Switching between place luck, recommendation, compatibility, and comparison made the app sound like four products. I now keep the core phrase consistent: today's personal place fortune.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Do not let a more dramatic demo replace the real flow
&lt;/h3&gt;

&lt;p&gt;Two scores moving against each other looked dynamic in a short video, but it trained viewers to expect a comparison product. The real result screen is less theatrical and more honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am measuring next
&lt;/h2&gt;

&lt;p&gt;The immediate goal is not downloads at any cost. I want to learn whether a store visitor can answer these three questions without extra explanation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this for one place or two?&lt;/li&gt;
&lt;li&gt;Do I use it before or after choosing the place?&lt;/li&gt;
&lt;li&gt;Is it entertainment or practical navigation advice?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those answers are unclear, more traffic will only create more confused visitors.&lt;/p&gt;

&lt;p&gt;Disclosure: I built &lt;strong&gt;Unse Nadri&lt;/strong&gt;, the Android app discussed here. It is free with ads and intended only as a light entertainment/reflection experience, not as a reliable prediction or advice tool.&lt;/p&gt;

&lt;p&gt;If you want to inspect the revised one-place flow, it is on Google Play:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.unsenadri.app&amp;amp;hl=en&amp;amp;gl=US&amp;amp;utm_source=devto&amp;amp;utm_medium=ux_case_study&amp;amp;utm_campaign=single_place_positioning_20260810" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.unsenadri.app&amp;amp;hl=en&amp;amp;gl=US&amp;amp;utm_source=devto&amp;amp;utm_medium=ux_case_study&amp;amp;utm_campaign=single_place_positioning_20260810&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I would especially value feedback on whether “today's personal place fortune” makes the core idea understandable in English.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ux</category>
      <category>buildinpublic</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I prevent unsupported places from looking 'live' in a Capacitor travel app</title>
      <dc:creator>Jaehoon Lee</dc:creator>
      <pubDate>Wed, 29 Jul 2026 16:07:18 +0000</pubDate>
      <link>https://dev.to/gaptripdev/how-i-prevent-unsupported-places-from-looking-live-in-a-capacitor-travel-app-fjf</link>
      <guid>https://dev.to/gaptripdev/how-i-prevent-unsupported-places-from-looking-live-in-a-capacitor-travel-app-fjf</guid>
      <description>&lt;p&gt;I ran into a product bug that was easy to miss because the UI looked polished: places outside the supported live feed could still carry a numeric crowd value from a catalog or fallback path.&lt;/p&gt;

&lt;p&gt;The number was valid JavaScript. It was not valid live data.&lt;/p&gt;

&lt;p&gt;That distinction matters in a travel app. A user may change plans based on a badge that says "live", so a plausible-looking fallback is worse than an honest empty state.&lt;/p&gt;

&lt;p&gt;This is how I tightened the boundary in &lt;strong&gt;Gap-trip&lt;/strong&gt;, an Android app built with JavaScript, Capacitor, Firebase, and public tourism data.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make provenance part of the type check
&lt;/h2&gt;

&lt;p&gt;Checking only whether a value is numeric was not enough. I added one predicate that requires both a number and evidence that it came from the supported live pipeline.&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;isRealtimeCrowdPlace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;place&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crowd&lt;/span&gt;&lt;span class="p"&gt;)&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;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crowdDataAvailable&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forecastSource&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;seoul-citydata&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&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;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;seoul-citydata-&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the exact source names. It is that "has a number" and "is live" are different facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Normalize unsupported values once
&lt;/h2&gt;

&lt;p&gt;After the predicate runs, unsupported crowd fields are removed before the object reaches the rest of the UI. Weather and air-quality data can still remain available.&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;hasRealtimeCrowd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isRealtimeCrowdPlace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;crowd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasRealtimeCrowd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crowd&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="na"&gt;usualCrowd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasRealtimeCrowd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usualCrowd&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="na"&gt;forecast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasRealtimeCrowd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forecast&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;forecastSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasRealtimeCrowd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;seoul-citydata&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;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;crowdDataAvailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasRealtimeCrowd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;crowdDataSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasRealtimeCrowd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;realtime&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;none&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this at one boundary is safer than asking every card, map marker, detail panel, and share image to remember the same rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Reuse the same predicate in filters
&lt;/h2&gt;

&lt;p&gt;A "show only places with live crowd data" toggle must use the same definition as the badge renderer. Otherwise the filter and the card can disagree.&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crowdOnly&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;isRealtimeCrowdPlace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds small, but duplicate definitions of "live" were exactly the kind of drift I wanted to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Degrade the score, not the truth
&lt;/h2&gt;

&lt;p&gt;When crowd data is unavailable, the place does not disappear. The recommendation can still use available conditions such as weather, air quality, travel time, transit access, and nearby facilities. The UI says that the score used the other available conditions instead of fabricating a crowd signal.&lt;/p&gt;

&lt;p&gt;This gives up some visual consistency: fewer cards have colorful crowd badges. I think that is the right trade. An empty field is recoverable. Misplaced trust is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Reject a partially broken batch
&lt;/h2&gt;

&lt;p&gt;A successful HTTP response can still contain too little usable coverage. The app therefore checks whether the live set is healthy before treating the batch as live. The current minimum is 30 supported places.&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;MIN_HEALTHY_REALTIME_CROWD_PLACES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasHealthyRealtimeCrowdSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;places&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;places&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;places&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isRealtimeCrowdPlace&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;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MIN_HEALTHY_REALTIME_CROWD_PLACES&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 backend also times out upstream calls, caches valid results briefly, and returns an explicit unavailable response when the live batch fails. A 200-shaped payload is not automatically a healthy product state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I test now
&lt;/h2&gt;

&lt;p&gt;The release scripts include crowd resilience, nearby coverage, soak, and failover checks. The cases I care about are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a valid live value with supported provenance;&lt;/li&gt;
&lt;li&gt;a numeric fallback with no live provenance;&lt;/li&gt;
&lt;li&gt;a partial batch below the coverage threshold;&lt;/li&gt;
&lt;li&gt;an upstream timeout;&lt;/li&gt;
&lt;li&gt;a place with weather data but no crowd data;&lt;/li&gt;
&lt;li&gt;the live-only filter after a failed refresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader lesson for me was simple: &lt;strong&gt;availability is a product claim, not just a data shape&lt;/strong&gt;. If the UI says "live", the code should be able to prove why.&lt;/p&gt;

&lt;p&gt;Gap-trip currently applies this rule only where supported public live data is available; it does not claim live crowd coverage for every place. If you want to inspect the behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://teumsae-nadri.web.app/?utm_source=devto&amp;amp;utm_medium=technical_article&amp;amp;utm_campaign=data_truth_boundary_20260730" rel="noopener noreferrer"&gt;Web version&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://play.google.com/store/apps/details?id=com.jaehoon.teumsaenadri&amp;amp;utm_source=devto&amp;amp;utm_medium=technical_article&amp;amp;utm_campaign=data_truth_boundary_20260730" rel="noopener noreferrer"&gt;Android app on Google Play&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would be interested in how other teams represent provenance when multiple public-data sources feed the same UI.&lt;/p&gt;

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