DEV Community

Cover image for How I Made Focusity Easier to Use: A Smarter Connect Screen and One Moodle URL
Meir Vaknin
Meir Vaknin

Posted on

How I Made Focusity Easier to Use: A Smarter Connect Screen and One Moodle URL

I build Focusity, a Hebrew study planner. It takes a student's exams and assignment deadlines and builds an actual study schedule around them: when to start preparing, how to split the material, what to do tonight.

All of that depends on knowing the deadlines, and getting them in was the worst part of the product.

The flow I wanted to delete

Open Moodle, find the calendar, click export, download an .ics file, dig it out of your phone's Files app, upload it to Focusity. Then a lecturer moves an exam by a week and you do the whole thing again.

Nobody repeats that. And a planner running on stale deadlines is worse than no planner, because it's confidently wrong about the one thing you're trusting it for.

Moodle does offer a better door. Every user has a private calendar URL:

https://moodle.college.ac.il/calendar/export_execute.php
  ?userid=45146&authtoken=<40 hex chars>&preset_what=all&preset_time=custom
Enter fullscreen mode Exit fullscreen mode

Paste it once. Something re-reads it forever. The student never touches a file again.

That's the whole feature. Most of the work turned out to sit in two places I hadn't planned for: the paste surviving a real phone, and the fetch reaching a college that didn't want to hear from my server.

How this actually gets built

I don't type most of my code. I build Focusity with Claude Code: I decide what gets built and what shape it takes, the agent writes it, and I read the diff. The commits sit in my git history under my name with a Co-Authored-By: Claude Opus 5 trailer, because that's what happened.

That matters for what follows. On the connect screen the agent caught defects I would have shipped without noticing. On the network bug it was no help at all, and why it was no help is most of what I learned.

The screen where you paste it

The feature is one dialog, and close to everyone who opens it is on a phone. That's where most of the work went, and almost none of it was the parser.

The first version had a picker listing the institutions we support. If your college wasn't on the list, there was nothing useful to click. The escape hatch, "my institution isn't listed", was an inline link inside a text-xs paragraph, roughly 16 pixels tall, sitting next to a second one for importing a file manually. Those two links were the entire path forward for exactly the students the feature was built for, at a size you cannot reliably hit with a thumb. Both are now full-width rows with a 44px minimum height, and the picker went from h-10 to h-11 for the same reason.

The picker also used to preselect the single institution we supported. On a phone that reads as a form already filled in with a college that isn't yours, so it stopped doing that.

The Focusity Moodle connect dialog in Hebrew, before an institution is chosen. The picker reads

The two rows at the bottom used to be inline links about 16 pixels tall.

Neither the picker nor the paste field had a real label. The picker leaned on a placeholder option and the paste field on a loose span next to it, which looks fine and tells a screen reader nothing. Both use label and htmlFor now. The two failure banners were visual only, so they carry role="alert", and the decorative icons inside them are hidden from assistive tech. A lock emoji that was doing structural work in the connected panel became a real icon like every other icon in the dialog.

The paste field is type="url" with autocorrect, autocapitalize and spellcheck off. A mobile keyboard will cheerfully capitalize the first letter of a URL you just pasted, and then the connection fails for a reason the student has no way to see.

The same dialog after choosing

The escape hatch, opened. The grey note under step one says any Moodle works, not only the ones in the list.

None of it is clever work, but it's the difference between a feature that works and a feature that works for someone who isn't me.

I want to be fair about where the agent helped here. I asked for a review against a UI and accessibility rule set, got five defects back with the tier they fell in, and fixed them in one pass. Left to myself I would have shipped the 16px links, because I'd tested the dialog on a laptop with a mouse, where they're perfectly clickable.

That's the same mistake I'm about to describe making with the server. I got caught for it twice in one day.

Where the request comes from

The original design was the obvious one. A table holds one URL per student. A scheduled function fetches the .ics every night, parses it, and updates tasks.

It keys on the calendar event's UID, so a moved deadline updates the existing task instead of creating a second one, and it only ever touches the name and the date. If a student marked something done, a nightly sync must not undo that.

I tested the pieces. I pulled the real feed with curl and got a valid calendar back. I ran the parser against that real payload and it produced the right course name and the right timestamp out of UTC. Two test harnesses, 23 assertions between them, covering the parser and the URL validator: plain http rejected, hosts off the allowlist rejected, the cloud metadata address 169.254.169.254 rejected, lookalike domains rejected, paths that weren't the export endpoint rejected.

Then I deployed it, connected my own account, and got last_sync_error: moodle_http_403.

The URL in the database was byte for byte what I had tested by hand, so the request itself had to be different. First guess was User-Agent, because college firewalls love rejecting anything that doesn't look like a browser:

curl default UA                  -> 200
no UA at all                     -> 200
Deno-like UA                     -> 200
browser UA                       -> 200
Enter fullscreen mode Exit fullscreen mode

Same token, same path, same headers. The only variable left was where the request came from, so I had the database make the call and also asked it where it thought it was standing:

select
  net.http_get('https://moodle.college.ac.il/calendar/export_execute.php?...'),
  net.http_get('https://ipinfo.io/json');
Enter fullscreen mode Exit fullscreen mode
403    <title>403 Forbidden</title>  Transaction ID: 32c9d7c1a891e526
200    { "ip": "18.139.36.225", "city": "Singapore", "org": "Amazon.com, Inc." }
Enter fullscreen mode Exit fullscreen mode

A 403 page with a "Transaction ID" is a firewall, not Moodle. My Supabase project runs in Singapore. The college is in Israel, and it doesn't take visitors from foreign data centers.

Nothing was wrong with my code. My code was standing in the wrong country.

Moving the fetch to the phone

I looked for a server-side answer first, because that's where everything already was. Ask the college to allow our address? Cloud IPs aren't stable or contractual, so that arrangement rots the first time our provider reshuffles and we find out when students complain. Move the server closer? The rule isn't "not Singapore", it's closer to "not a hosting provider", so Frankfurt would very likely get the same treatment, and you can't relocate an existing project anyway. Run a proxy inside the country? Then I'm paying for infrastructure per country, forever, so that one feature works.

Meanwhile my own phone, on cellular, loaded the export page without complaint. Students are exactly who that firewall was built to let in.

So the fetch moved to the phone, and there's one catch worth knowing if you try this. Focusity's mobile app is a WebView, and fetching the file from the JavaScript already running in there doesn't work, because the Moodle export sends no CORS headers whatsoever:

$ curl -sSI -H "Origin: https://app.example.com" "$URL" | grep -i access-control
(nothing)
Enter fullscreen mode Exit fullscreen mode

No Access-Control-Allow-Origin means a browser context won't hand the response to your script. But a native fetch isn't a browser context, and CORS doesn't apply to it. The React Native layer can make the request the WebView can't.

The app already had a bridge for this shape of problem, since native Google sign-in posts a message in and gets an answer injected back, so this became one more message type. Which route runs is decided when the screen opens: device if the app is new enough to answer, server otherwise. An older build simply never replies, times out after four seconds, and falls back.

The part I didn't plan

For a blocked college the server can never use that URL. So why was I storing it there?

On the device route I moved it to the phone's keychain. Written by the device, read by the device, never transmitted to my infrastructure at all. The server route still exists for colleges that don't block us, where an overnight sync beats one that only runs when the app opens. But this was built for the students behind those firewalls, and for them my database no longer holds a live credential to a college account, so it can't leak one.

The host allowlist went the same way. It existed because my server made the request, and an unvalidated host there is a request-forgery primitive. On the device path the phone makes the request, so there's no infrastructure of mine in the path to abuse. The list was buying no security there and costing real students access, so the device path dropped it and kept every structural check. Which is also why the picker can now grow from evidence: a successful connection reports its hostname back, and an institution joins the list because someone actually connected from it.

That's a better design than the one I set out to build, and I didn't reason my way there. A firewall said no, and only then did the stronger option become obvious.

What I'd do differently

The architecture wasn't the mistake. The order of my tests was.

I verified the URL, the token, the parser, the validator, and the dedup logic. I tested the network path from my laptop, which sits on the same kind of connection my users have, and never once from the environment the code would actually run in. That check was a single query away the whole time, and I only ran it after production made me. It's the same shape as the 16px tap targets. Both were invisible from a laptop, because neither was really about the code.

Working with an agent made that easier to fall into. Claude Code will write every test you can describe, and more of them than I would have bothered to write by hand, which is exactly what made me feel covered. But the tests all pointed at the code, because the code is what's in the diff. The Singapore address isn't in the diff. Nothing about the files you're both looking at says this will run from a data center on another continent, at an address someone else's firewall has an opinion about. Deciding that the environment belongs in the test plan isn't a coding task, and nobody was going to do it for me.

So here's the rule I'm keeping: when your code will run somewhere other than your machine, test from that somewhere first. Your laptop is not a staging environment, and the browser on it is not a phone. Each one has a reputation with other people's systems that you never get to see.

I study industrial engineering and management, where a lot of the degree comes down to why systems break at the handoffs, in the places where one component quietly assumes something about another that isn't true. Then I built something that broke at the interface between my server and someone else's firewall, and at the interface between my mouse and somebody's thumb. The theory was fine. I just didn't apply it to my own work until it broke.

What a student gets out of it

You paste one link, once, at the start of the semester. After that, when a lecturer pushes an exam back two weeks, your plan moves with it. You don't re-export anything, and you don't find out in week eleven that you've been preparing against a date that changed in week six.

If your college is one of the ones that blocks us, your login credential also never reaches my servers. It sits on your phone. If you're going to trust a small app with something attached to your degree, that's the shape the trust should have. It happened because a firewall forced my hand, which I'd rather say plainly than dress up as foresight.


I'm a student, and Focusity is what I build when I should probably be studying. The device-side sync is written, tested on a real phone on cellular, and sitting in a TestFlight build. It reaches the App Store when the next release does.

Top comments (0)