If you have ever written a contest proposal, you know this moment. You skim the data catalogue, think "that should be enough", and write the feature down. Before ever calling the API.
My sentence read like this: "Using telecom, credit card and navigation big data, analyse hourly visitor counts per attraction to compute a crowding index and recommend the best time to visit."
The idea itself fits in one line. Compute crowding from tourism big data, then invert the ranking. Instead of showing you the place with the most reviews, show you the quiet-but-good place first. Overtourism, mitigated with data.
I submitted the proposal in late March and passed the preliminary round. The app shipped on the App Store in May, and I submitted the first-round review materials on 10 August. In the four and a half months between, the app went through three App Store rejections and roughly ten "the screen silently goes empty" bugs — and one feature from the proposal was dropped entirely.
Start with why it was dropped.
The data I promised did not exist
Here is what I found once implementation started.
- Credit card and navigation data are not in this API family. Zero calls. There was never an endpoint.
- Visitor data exists — but monthly, per district. Not per attraction, not per hour.
- Per-attraction data exists too — but as a daily concentration percentage. No hour dimension.
So nothing in the open API can answer "what time is it quiet". My assumption — that a name in the catalogue implies the granularity I want — was wrong.
Except the app already had the feature. The detail screen had a "crowding trend" chart and showed a "best time to visit". The screen existed without the data.
Tracing the values back led to this, in the batch code:
visitors_by_hour: Array.from({length: 24}, () => Math.random() * 100),
visitors_by_weekday: Array.from({length: 7}, () => Math.random() * 100),A mock, added early so the UI could be built first. I forgot to swap it for real data, and it shipped. Any judge opening any attraction would have seen a random-number graph presented as "big data analysis".
That was the most dangerous moment in the project. A bug you fix; this was not that kind of thing. Without the data, the feature cannot exist.
What would you do here? Improvise a plausible estimation model to keep the chart alive, or pull the feature from the proposal and eat the scoring penalty?
I did three things.
- Turned the chart off behind a flag. Not deleted — gated. If hourly data is ever opened, it comes back.
- Put the 30-day average of the daily concentration rate on the detail screen instead. I changed the question to one the available data can answer. It cannot say "what hour is quiet", but it can say "how busy is this place usually".
- Rewrote the proposal. Removed every reference to card, navigation and spending data, and moved the hourly recommendation into the roadmap section as "not currently provided by the open API; an in-house model is in preparation".
Number three scared me. Removing a feature from a proposal looked like a guaranteed deduction. Then I read the contest FAQ: "If there are changes from the submitted proposal, reflect them and proceed." The submission guide also said to write the description "reflecting the final service as completed".
The organiser already expected proposal and deliverable to diverge. Dropping the feature and stating the reason beats leaving random numbers in place. It still took me several days to make that call.
Two agencies use different numbers for the same district
I registered in the regional track with Busan. It carries bonus points and a separate regional tourism award. The problem: a judge must not open the app and think "this is a nationwide service registered as regional".
I needed something that only worked in Busan. The natural unit is the district (gu/gun). Even inside Busan, Haeundae is packed while Geumjeong is quiet. Show that gap and the story completes itself: alternatives without leaving the metro area.
The data was already there. Per-district concentration rates were loaded in a table. And still I could not build a district-level screen.
The reason is deflating. The tourism API's district code is 2 digits; the concentration dataset's district code is the 5-digit Ministry of the Interior code. Same Haeundae — 1 on one side, 26350 on the other. No rule converts between them. The tourism API's 2-digit code is an internal number valid only inside that API, so there is nothing to join on.
Not knowing this cost me months of not building district features. I thought the data was missing. The data was on both sides; the join column was missing.
The fix was one dimension table.
create table sigungu_dim (
region_code text, -- province (tourism API scheme)
sigungu_code text, -- 2 digits (tourism API)
signgu_code text, -- 5 digits (Ministry of the Interior)
name_ko text, name_en text, name_ja text, name_zh text
);I seeded Busan's 16 districts, pulling the four language names from the tourism API's region-code lookup. Once the table existed, the district filter and the per-district crowding comparison card landed in two days. Measured values spread from 45.0% (Geumjeong) to 70.1% (Jung-gu), and that bar chart ended up being the only concrete evidence for the regional-track claim in my feature document.
The lookup function needed security definer, because anonymous select on the base table had already been revoked. And when adding a parameter, do not create an overload — the REST layer fails on ambiguity. drop it and recreate a single signature with a default.
The lesson is not a pleasant one. When mixing two families of public data, never assume the region codes share a scheme. Joining on names does not work either (several cities have a "Jung-gu"). Hand-building a mapping table is the orthodox answer, and it saves months.
The recommendation logic only worked in Korean
The app supports four languages, pulling each language's tourism content from that language's own API. Not machine translation — that was the differentiator.
I opened the app in English. The list rendered, but every score badge on every card was 0. The inverse-crowding sort was not running. The entire reason this app exists is inverse-crowding recommendation, and it only worked in Korean.
Cause: the tourism API assigns a different content ID per language for the same place. Haeundae's Korean ID and English ID are different values. My crowding score table was keyed on Korean IDs only, so the English list failed to join. 231 matches out of 4,310 rows.
I calculated that a coordinate-proximity join (within 55m) would recover about half. That is treating the symptom. The root cause was that the scoring batch only scored the Korean locale. Making it score every locale took the join rate to 100%.
This bug has a nasty property. The developer uses the app in Korean. So nobody saw it for four months. In a service whose selling point is real multilingual content, the core feature was dead in three of the four languages. I have hit the same class of failure in the simulator ignoring the language flag and in dead /ja links. Locale is my repeat offender.
A different app was serving at the URL the judges will open
Submission forms ask for a service URL. It is also where the data-source attribution is published, and the same address appears on the data storage approval application.
Six days before submission I checked that address. Another app's pages were being served. All four files — about, terms, privacy, support. A deploy days earlier had uploaded the contents of a different app's directory (I've written about the same accident before).
There is a specific reason I found it so late. This domain's CDN serves a challenge page to non-browser requests. Check the status code with curl and you get 403, because you are not a browser. The HTTP response cannot tell you whether a deploy succeeded. It is 403 when healthy and 403 when corrupted.
So I changed the verification. SSH in, hash the server files, compare against local. Even the sizes give it away — healthy pages are 27–79KB, the wrongly uploaded stubs were 1–5KB.
ssh <host> 'cd ~/public_html/<app> && for f in *.html; do
echo "$f|$(stat -c%s $f)|$(md5sum $f | cut -d" " -f1)"; done'
# diff against local md5index.html |27531|f7370646663da6a3905be0d6e499aaf0
privacy.html|79397|fac1f82a1503d51d2ffd1c5a40745a95After restoring, I swept all 50 app directories to check the rest. I re-compared on submission day; all four md5s matched.
Lesson: if the signal you use to judge deploy success cannot distinguish failure, that check is not a check.
The compliance direction reversed mid-project
The announcement said: do not use the organiser's name in your service name or logo. I read the rule broadly and removed all 41 occurrences of the agency name from the app and the site — four languages × four files, bulk replaced.
Two months later the FAQ appeared. Summarised:
- Naming the agency as data-source attribution is required
- Using the API service name alone is discouraged
- What is prohibited is putting the agency name in your service name or logo, or using its CI/BI assets without permission
So the rule's intent was "do not make it look like the organiser built this", and attribution was in fact mandatory. Deleting those 41 occurrences overshot. I had to reverse it, this time adding a data-source section to app settings and provider attribution on detail, image and regional-insight screens. Store metadata in four locales got the same treatment.
I did the work twice. Between reading a rule narrowly and reading it broadly, broad looked safer — and in this case the broad reading moved me closer to violating it.
Four traps on submission day
The deadline was six weeks out; I submitted on the opening day. These are what that bought me.
The attachment was over the size limit. The feature document had a 10MB PDF cap and my file was 10.41MB — 400KB over. Investigating the actual placed size of every image inside the PDF, the weight was not my app screenshots. It was the background graphic in the organiser-supplied template, embedded at 3999×2250, 2.7MB on the cover page alone.
2375KB 1320x2868 -> 1.39x3.03in DPI=948 <- app screenshot (what judges zoom into)
2748KB 3999x2250 -> 13.33x7.5in DPI=300 <- template background (cover page)Downsampling just those seven brought it to 6.82MB. The screenshots and flow diagrams judges actually zoom into were untouched. Someone else's template outweighed everything I put in. The instinct to suspect your own assets first was wrong here.
A radio button in the form was set to the wrong option. The test account type was set to "ID format" while the account I had actually created was an email address. Submitted as-is, judge login fails — and by the rules that is grounds for disqualification. I did not know until I looked at the rendered screen. Review forms with screenshots, not text dumps — radio and checkbox state does not appear in text.
Six APIs were checked: one I do not use, and three missing. The concentration API in particular was absent. Without it, the attached feature document (which cites it as the basis for district comparison) contradicts the submission record. Meanwhile a language service I never call was checked — and declaring an API with zero call history is exactly what verification catches.
Putting the same value in both API key fields made the form reject it. "A duplicate API key value exists." The public data portal issues each key in two forms: the raw (decoded) key and its URL-encoded twin. Normally + becomes %2B and = becomes %3D, so the two differ — hence two fields, and identical values read as a copy-paste mistake.
Our key was 64 hex characters. It contains no URL-reserved characters, so encoding is a no-op.
from urllib.parse import quote
k = '<64-char hex>' # 0-9a-f only
quote(k, safe='') == k # True — nothing to escape
# compare with a Base64-style key:
# 'abc+def/ghi==' -> 'abc%2Bdef%2Fghi%3D%3D' (two different values)Both copy buttons on the portal produce the same string. Not our input error — a property of the key format.
I considered reissuing the key, and not doing it was right. The organiser queries call volume during the development period using the submitted key. More than 340,000 rows were loaded under the current key; a fresh key could show zero history. On top of that, 29 batch cron jobs sign with it, so reissuing stops the running pipeline the moment it happens. And by issuing policy the new key would be hex again.
I could also have force-percent-encoded something, or changed the case, to make the two fields differ. The form would accept it. And then the organiser would query that key and find nothing. That is discarding the evidence in order to pass the check on the evidence. I filed a support request instead, and the operators changed the form to accept identical values. This one was not mine to fix.
Three self-checks
Only the parts you can apply to your own project right now.
- Grep your repo for
Math.random(),mock,TODO,dummy. If any hit sits on a path the shipped build reads, it is already on someone's screen. - Does your deploy check distinguish success from failure? If a healthy response is also 403, that 403 verifies nothing.
- Open your app in a locale you never use. Check whether scores and sorting render, not just the list.
Failures and limits
Stated plainly.
- Mock random numbers shipped to production. A placeholder added to build the UI first was never replaced, and it went into the app under review. I found it re-reading code. Nothing caught it automatically. The real defect is having no convention for marking placeholders and grepping for them before release.
- A multilingual defect went unseen for four months. It worked in the language the developer uses. There is no per-locale smoke test. There still isn't.
- I did the compliance work twice. Read the rule broadly, deleted 41 occurrences, reverted them. When an FAQ is still pending on a rule, handle it in an easily reversible way until it lands.
- I sent an application unsigned. While preparing the data storage approval application I created a checklist with a "sign and seal" item — and sent it with that item incomplete. It was rejected. Making a checklist and reading it are different activities. Re-submission took four days, and as I write this I am still waiting for approval.
- Some bugs only appear on device. The map SDK does not render markers in the simulator, and AR cannot run at all without a camera. So I first saw the map pins clustering in the wrong place on a real device. Nearby-attraction lookup was cutting the top 200 by score rather than by distance, and since scores are district-level, one district ate all 200 slots. Open it in south-east Seoul and you got pins in the north. A judge opening the map tab would have seen it immediately.
- AR falls short of the promise. The proposal said AR guides for the top 50 attractions. The real anchor count is 5. Growing metadata without reference images breaks recognition, so I did not inflate it; I removed AR from the flow diagram in the feature document and left it in the feature list only.
- The hourly recommendation was never built. Because the data does not exist. I wrote "in-house model" into the roadmap and have not done it. The only principle I kept was: no unfounded hour estimates.
Apple rejected the app three times: (1) mixed languages in the store copy, (2) age rating declared no user-generated content while a community feature existed — and that community was empty, (3) no blocking feature. Three different reasons; only the last needed a new build. The others were metadata and data seeding.
Cumulative load at submission: 347,315 rows (four languages of tourism content, visitor counts, concentration rates, demand intensity and diversity), running with zero errors.
Conclusion
The biggest thing I take from four and a half months is not technical, it is ordering. A proposal is not a document you write from the catalogue; it is a document you write after one call. I did it the other way round, and paid for it with a feature.
And dropping the feature turned out to be safer than it felt. Reading the rules, the organiser already assumed proposal and deliverable would differ. The dangerous move was never removing a feature — it was drawing data that does not exist as though it does.
Go grep your repo for Math.random( right now. If a hit lands anywhere near your UI, I would genuinely like to hear about it. I doubt I am the only one.
App: HiddenGem