출석 알리미
A serverless phone-only app that rings a few minutes before class and, when you tap the notification, shows the attendance-window countdown and opens the university's electronic-attendance app - a one-day MVP where the platform constraints, not the features, are the whole story.
- Expo / React Native
- TypeScript
- iOS / Android
- Local Notifications
- QA
Setup
- Problem
Electronic attendance gives you a short window around the start of class to tap check-in inside the school's own app. That window opens and closes exactly while you're in transit or just walking into the room, so missing it isn't laziness. There's simply no signal that says "this is the moment." What was needed wasn't a feature but a one-step path: the phone rings first, and tapping that notification takes you straight into the attendance app. I imposed the harder constraint myself - no server, no account, everything inside the phone. With zero data collected, the privacy handling and store review burden disappear before they start. Building it, the hard part turned out not to be the path. It was that iOS and Android each block, in their own way, "queueing notifications far into the future" and "opening someone else's app for you." This case is the record of handling those three constraints in the design.
- Context
This is a personal MVP finished in nine commits inside a single day. It was never published to a store, and the repo holds no record of on-device verification. I'll state up front that there's nothing here to sell on size. What I did write down before the feature list was "what will stop me?" The moment I committed to local notifications only, the iOS cap of 64 pending notifications per app collided head-on with a semester-length schedule. The moment I committed to opening another app, Android 11+ package visibility and the fact that the target app publishes no public URL scheme were both waiting. Those three were design inputs settled before any screen was. I also decided to keep the university's name and logo out of the app name and icon, and wrote the reason into the README: trademark review risk.
- Users
Me, a graduate-program student taking evening and weekend classes. It was never released to a store, so nobody but me has used it. The README describes a closed test with a handful of classmates, but that stayed a plan and there is no trace of it being run.
- Hypothesis
If attendance gets missed not from forgetfulness but from the absence of a "this is the moment" signal and a one-step path into the app, local notifications on the phone alone should be enough, with no server behind them.
Build
- What I did
- Local notification N minutes before class starts (3 by default) - each session scheduled as its own DATE trigger, and every time the app returns to the foreground the whole queue is cancelled and the nearest 60 are re-scheduled
- Tapping the notification opens a countdown of the attendance window, and on default settings the attendance app follows right after
- Ten class-period presets applied by checkbox on top of a Term - weekday, first-half weekend, and second-half weekend date ranges are derived from the preset kind, and marking a cancelled class on the calendar drops just that session from the schedule
- Self-recorded attendance - three states per date (O for present, △ for late, blank), cycled by tapping the cell
- The launch path into the attendance app - Android goes by package-name intent (a config plugin I wrote injects <queries> into AndroidManifest), iOS goes scheme, then Shortcut, then the store only if the user presses "open store" themselves
- Time-dependent logic pulled into pure functions in src/
schedule.tswith now injected as an argument, so the decision boundaries are testable without a device - 17 unit tests on node:test - A test notification 10 seconds out - a self-check meant to walk the app's real path (permission, delivery, tap, app launch) in one pass on a device
- Product decisions
- No server, no account, no login at all - with zero data collected, the privacy handling and store review burden never begin. The price I accepted is that user counts and notification delivery rates are structurally impossible for me to collect
- Cap the scheduled queue at 60 - four below the iOS limit of 64 pending notifications per app. Fill the cap and the later sessions of the term get quietly evicted by whatever else arrives
- Never open the store automatically when the app fails to launch - most launch failures aren't "it isn't installed" but "the scheme or shortcut is configured wrong," and an automatic jump to the store sends the user somewhere irrelevant and hides the actual cause. The code comment pins this as never automatically
- Term dates live in a Term type rather than hard-coded - the first-half and second-half preset ranges derive from it, so a new semester means editing dates and all ten presets follow
- The university's name and logo stay out of the app name and icon - trademark review risk. No reason to get a personal convenience tool stuck in review
- The README draws a hard line that attendance records here are a self-kept log, separate from official school attendance - the moment someone believes this app's O and △ equal the school's system, the tool stops helping and starts endangering
- QA considerations
- Do late-semester notifications quietly vanish against the iOS 64-pending-per-app cap - the queue is capped at 60 and, whenever the app returns to the foreground (AppState active), fully cancelled and re-scheduled nearest-first so it keeps rolling. The residual limit remains, so the README says plainly that you should open the app now and then during the term
- Does launching by package name fail silently under Android 11+ package visibility - a config plugin I wrote (plugins/
withAndroidQueries.js) injects <queries><package> into AndroidManifest, with a duplicate-entry guard - The target app publishes no public URL scheme, so a single launch path means total failure - iOS checks the scheme with canOpenURL, falls back to running a Shortcut, and only reaches the store when the user presses "open store" themselves. The failure message also branches on whether a shortcut name was entered, so it tells you what to do next
- Does time-based judgement get welded to the device clock and become untestable - the decision logic is a pure function with now injected, pinning both sides of every boundary: 18:57 before, 19:00 and 19:15 open, 19:16 and 19:30 late, 19:31 closed. The default behaviour where the late phase disappears entirely if lateUntilMin is unset is covered as its own case
- Is the number of scheduled sessions itself right - tests pin 15 Tuesday sessions (16 minus one cancellation), 8 first-half and 8 second-half Saturday sessions, and the first and last session of a course that starts mid-semester
- Does automatic recording undo a manual correction - attendance writes are idempotent so an already-recorded session is never overwritten, and the notification handler remembers the identifier it processed so a cold start or a return from background doesn't run the same response twice
- Does the app survive old saved data and corrupted JSON - a missing lateUntilMin key is read as an older settings version, resetting only the timing and iOS keys to new defaults while keeping the rest; corrupted AsyncStorage JSON falls back to defaults in a try/catch; old attendance records without a status are filled in as present
Outcome
- Metrics
- 17 unit tests - 10 in
schedule.test.tsplus 7 inpresets.test.ts(7 describe blocks, 43 assert calls) - 1,847 lines of TypeScript (
App.tsxat 288, an 18-line config plugin, tests included) - 6 screens (home, period picker, class editor, attendance table, alert, settings) and 10 class-period presets
- Scheduling capped at 60 - four spare against the iOS 64-pending-notification-per-app limit
- 12 runtime dependencies, 0 server endpoints, 0 external API calls, 0 data collected
- 9 commits, all inside a single day on 2026-08-30 (18:30 to 22:05 KST)
- Not released on any store - the Play listing 404s and the App Store lookup returns zero results. User counts, notification delivery, and attendance success rate are all unmeasured, and with no server there's no way to collect them
- 17 unit tests - 10 in
- Result / Learning
What came out in a day is a personal MVP. The code path runs end to end on paper: notifications get scheduled, a tap is handled, the attendance-window countdown appears, and the attendance app is launched. That is the extent of it - no store release, no users besides me. Whether the notification actually fired and the app actually opened on a real phone, I cannot prove from the record, because the repo holds no screenshot, log, or report. That also leaves the hypothesis untested: showing that local notifications alone keep you from missing attendance would take someone other than me using it for a full term, and I never got that far. What I took from it is that the size of a feature list and the difficulty of a design are unrelated. This is a six-screen app, and every place the time actually went was something the OS forbids. You cannot queue notifications indefinitely, you cannot freely see whether another app is installed, and the other app will not open its own door. The features take half a day; those three each demanded a different way around, and writing the cost of each detour honestly for the user (you should open the app occasionally, you have to build the Shortcut yourself) was part of the design too. The decision that paid best was pulling the time logic into pure functions with now injected. That one separation is why 19:15 versus 19:16 and 19:30 versus 19:31 could be pinned without a device. A one-day project ended up with 17 tests not because there was spare time, but because the code had already been cut into a shape that could be tested.
- Retrospective
- A specific URL scheme string sits in the iOS defaults, while the README text says the opposite - that no public scheme exists, which is why you build a Shortcut. The code default and the document contradict each other, and there's no record of that scheme ever working. Without evidence, it shouldn't have been a default at all.
- The first Android launch step wraps IntentLauncher.openApplication in a synchronous try/catch with no await. If that API returns a promise, a rejection never reaches the catch and the call reports success immediately. A failure reported as a success is the worst kind of bug, so on-device confirmation sits at the top of the next task list.
- The price of finishing it in one sitting is that there is not a single record of on-device verification. Pure functions are covered by tests, but this app's real path - permission, delivery, tap, app launch - is exactly where unit tests do not reach. Building a 10-second test notification specifically to check that path and then recording nothing is self-contradictory. The EAS config and project settings are committed, but no build output or log is, so I cannot even say from the record that a build ever ran. The untouched template LICENSE and the
package.json name that disagrees with the slug are traces of the same rush.
- Tech stack
- Expo SDK 57
- React Native 0.86.3
- React 19.2.3
- TypeScript 6.0
- expo-notifications (로컬 DATE 트리거)
- expo-intent-launcher
- expo-linking
- AsyncStorage
- node:test + tsx
- Expo config plugin (AndroidManifest)
- EAS Build