Skip to content

Land the mobile app: issues #3–#18 - #37

Merged
wak327 merged 18 commits into
mainfrom
mobile-offline
Sep 11, 2026
Merged

wak327 merged 18 commits into
mainfrom
mobile-offline

Conversation

@wak327

@wak327 wak327 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Why this exists

PRs #21–#36 all show as merged, but none of that work reached main. They were stacked, so each one merged into the branch below it rather than into the default branch. main currently contains only the foundation (#1) and login (#2).

This is the integration PR that actually lands the rest. mobile-offline is the top of the stack and a strict superset of main; the merge is clean.

Consequence worth knowing: GitHub's Closes #N keyword only fires on merge to the default branch, which is why #3–#18 are all still open despite their PRs being merged.

What lands

Issue
#3 Registration and invite acceptance
#4 Teams: home, switcher, creation
#5 Board and list views with filters
#6 Issue creation and editing
#7 Issue detail: sub-issues, links, development, watch
#8 Comments and GitHub-flavored markdown
#9 Attachments: camera, library, files
#11 Search
#12 Cycles
#13 Reports: burndown, velocity, CFD, created vs resolved
#14 Saved views
#15 Profile and account settings
#17 Site administration
#18 Offline support and sync

Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #9
Closes #11
Closes #12
Closes #13
Closes #14
Closes #15
Closes #17
Closes #18

Deliberately not closed

#10 (notifications) — the in-app inbox and unread badge land here, but push does not exist and cannot from the client: there is no device-token endpoint and nothing APNs- or FCM-shaped in the schema. The issue itself anticipated needing a backend addition. Remaining scope is that addition plus the client side that depends on it.

#16 (team administration) — everything the API exposes lands: general, members, invitations, statuses, automation rules (read, enable, disable, delete, run log) and repositories. Rule authoring does not — a trigger plus six optional conditions plus seven optional actions is a form for a wider screen, and the panel links out, as the issue already allows for Jira import.

Both are referenced rather than closed so the remaining scope stays visible. Close them by hand if you'd rather track the remainder elsewhere.

Verification

159 unit tests and 63 integration tests against a running instance, clean typecheck, zero lint errors, 21/21 expo-doctor, both platforms bundling.

Nothing has run on hardware. The board's drag gesture, the medium/expanded layouts, and offline sync are covered as logic but not as behaviour — each has a non-gesture path that is exercised, but they want a real device pass.

wak327 and others added 18 commits September 11, 2026 21:45
Closes #4.

A tab bar has no room for the web's per-team URLs, where every board is its own
address. So the active team lives in a context and is persisted, and /ENG is a
redirect that sets it rather than a second shell. Lowercase tab routes can never
be shadowed by a team key, because the backend uppercases keys on the way in.

Member counts cost one request per team and that is deliberate: TeamRead carries
no count, GET /teams returns only your own teams, and the design asks for them.
Counts stay undefined rather than zero while loading or on failure, so a row
shows no count instead of claiming a team is empty. A member_count field
upstream would remove the fan-out and help the web too.

The switcher and the create form share one sheet component that enters from the
bottom on a phone and from the side on anything wider, because a full-width
panel sliding up over a multi-pane layout would cover work still being read.
The key is suggested from the name rather than imposed, since it is permanent
once created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #3.

The invitation screen sits outside both route groups, guarded by neither: most
people opening one of these have no account yet, and bouncing them to a login
screen without saying what they were invited to is how an invitation gets
ignored. It mirrors the web keeping /invite/:token outside RequireAuth.

One thing the web gets for free that this cannot: on the web the page's own
origin IS the instance, while a softtrack:// link carries no host. Arriving with
no known instance asks for sign-in first and comes back, rather than guessing.
True universal links would need each self-hosted domain declared in the build,
which is not possible for arbitrary hosts -- noted in the README rather than
papered over.

Registration derives its email from the invitation when there is one, because
typing a different address would only earn a 403 on submit. The backend accepts
an invite best-effort after creating the account and swallows failures, so the
team list is re-read rather than assumed.

The in-app banner is the only thing that tells an existing account an
invitation exists, since nothing sends email.

Verified against the reference stack, including the 422 whose `detail` is an
array rather than a string -- the case the web's extractor drops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #5.

Columns come from the team's own statuses, so nothing is hardcoded -- a team
that renames "Cancelled" to "Won't do" still gets it collapsed by default,
because the rule is by category.

Filters live in the route's query params rather than component state, the same
choice the web makes and using the same short keys. That is what makes the board
someone is looking at a link they can send, and it means a board URL copied out
of the web app narrows this one the same way.

Per-column point totals come from the server's own rollup rather than being
summed from the loaded page: the issue list is one page, so a client-side total
would quietly under-report a long column. The blocked marker is read straight
off blocked_by_count for the same reason -- no per-card link fetch.

Dragging is one Pan gesture with activateAfterLongPress rather than a long-press
composed with a pan, so a column keeps scrolling vertically until a card
actually lifts. Hit-testing converts the finger's screen position into the
content coordinates the columns were measured in, which is what makes a drop
land correctly while the strip is mid-scroll, and a card held against an edge
keeps scrolling from a frame callback rather than waiting for the next move.
A tap opens the same move-to-status sheet, so the board is usable without the
gesture at all.

Shared values are written through .set()/.get() rather than .value: the React
Compiler treats a property assignment on a prop as a mutation, and the method
API is what it accepts.

Verified against the reference stack -- filters narrow server-side, a moved card
stays moved, and the filtered view agrees -- plus the board tree renders under
jest, which needed the worklets jest resolver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #6.

Every property the web's new-issue modal offers, as tappable rows opening a
sheet rather than a column of selects -- a phone has no `<select>` worth having.
Only the title is required, so capturing a thought costs one field and a tap.

On the detail screen properties are written through as they are picked rather
than collected behind a Save button: each is a single-field PATCH, and a form
that must be submitted invites half-made edits to be lost when the screen is
dismissed. Title and description are the exception, because they are typed, so
they save on blur.

"Not sized" and "Unassigned" get their own rows rather than being expressed by
closing the sheet. The API distinguishes them from an absent edit, and the
board's point totals read differently for an unsized issue than a zero one.

Labels and projects can be created inline, as the web allows any member to do --
leaving for team settings to add a label mid-triage is worse on a phone than at
a desk.

The detail screen resolves its team from the issue rather than the active one,
since a link can arrive from anywhere.

Verified against the reference stack: the identifier is server-allocated as
TEAMKEY-n, every property round-trips, the estimate scale is exactly what the
picker offers (4 is refused), clearing back to null works, and a deleted issue
stops being listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #7.

Sub-issue progress reads the issue's own child_count and completed_child_count
rather than tallying the child list: the server excludes cancelled children from
both, so re-deriving it here would quietly disagree. Verified against a live
instance -- cancelling a child leaves the denominator rather than counting as
outstanding work forever.

Only three link types can be created. The inverses (blocked_by, duplicated_by)
are derived server-side and appear on the other issue, so they are listed but
not offered, with a line in the picker saying so. A blocking link is also what
drives the board's blocked marker, so adding one re-reads the issue list.

Development is read-only and hides itself when empty -- those rows arrive by
webhook from the repository side, so there is nothing to create, and a
permanently empty section is just noise on a phone.

Watch reads the current state rather than assuming it, because the server
auto-watches on assignment and commenting: the toggle reflects something that
changes without being touched here.

The link target picker filters the team's already-cached issue page on
identifier as well as title, since "ENG-142" is what people actually type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #8.

GFM comes from react-native-marked, except task lists, which it cannot render:
its parser drops marked's `task` and `checked` flags before the renderer sees
them, so a checklist would come out as bare bullets with no box. The source is
lexed at block level here, task lists are drawn directly -- which is also what
makes them tappable -- and every other block is handed back to the library by
its own raw text.

Mentions and TEAMKEY-123 links are resolved in the renderer's text() hook, which
only ever receives text tokens. That is what keeps `@demo` inside backticks or a
fenced block literal -- the same property the web gets from running this as a
remark plugin rather than a regex over the source, and an issue tracker is full
of shell snippets.

Ticking a checkbox edits the source at that one character rather than
re-serialising the parsed markdown, so bullets, indentation and trailing spaces
the author chose stay exactly as typed. Verified byte for byte against a live
instance.

The `@` menu is driven by the caret rather than the last keystroke, so it also
opens when you move back into a half-written handle, and stays shut on the
second `@` of an email address.

An unknown handle stays plain text: far more likely an email fragment or a shell
variable than a person.

Comments with no author are automation rules, and render as such.

One correction on the way: I assumed commenting always starts you watching. It
does not -- auto_watch only adds a row when you have never expressed a
preference, so unwatching and then answering a question does not resubscribe
you. The test now pins that actual contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #9.

Uploads bypass the generated client: Orval emits a Blob body, and React Native
has no real Blob to give it -- a picked file is a file:// URI the native layer
streams itself, which is what `{uri, name, type}` in FormData expresses. That
also gives a progress callback, which reading the file into memory first would
not.

The server's allowed list is mirrored client-side so a phone refuses a file
before spending a minute uploading something that was never going to land, and
the SVG message says *why* it is refused -- it is a document that can carry
script, so a generic "not allowed" would read as an oversight rather than a
decision.

Attachment bytes are authenticated, so images are fetched with the bearer token
in a header rather than the query string, where it would end up in whatever logs
the instance keeps. A plain image source would simply 401 -- there is a test
that pins exactly that.

Two corrections from checking rather than assuming: a disallowed extension is
415, and a file whose bytes do not match its name is 422 with the server naming
the format. Both now assert what the server actually does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #11.

Matching, ranking and scoping all stay on the server, which is what makes the
result set the same one web shows -- the client only settles the query before
sending it and pages through what comes back.

Two characters before the first request: a single character matches most of an
instance and is never what anyone meant.

Each result says where the match was found, because a hit on a comment and a hit
on a title look identical otherwise, and carries the snippet that explains why
it is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #14.

Which view is showing is decided by comparing filters rather than by tracking an
id, the same way the web's sidebar does it. That is what lets a pasted link
carrying filters light up the matching row for someone who can see the view, and
still work for someone who cannot.

The board lands on the default from the server's own `effective_default_id`
rather than re-deriving yours-then-the-team's on the client -- one place decides
that order, and it is the one that also answers the web. Landing happens at most
once per team, so clearing the filters on purpose does not bounce straight back.

Filters translate through the same mapping the web uses, including `unassigned`
being a flag beside assignee_id in storage and one value of assignee on the
board -- the flag wins on the way back, as it does server-side.

Verified against a live instance: a view stores what the board was showing, is
private until shared, and a personal default overrides the team's while null
clears rather than sets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #12.

Progress comes from the server and carries the unsized-issue count alongside the
points total, which the banner and the cards both show. A cycle whose points
look complete while three issues were never sized is not finished, and that
count is the only thing that says so.

Completing a cycle reports where the unfinished work went -- the next cycle or
the backlog, whichever the server chose. Without that, "completed" reads as
though those issues had been finished.

Dates are validated before submitting, including end-before-start, so the form
answers rather than the server.

Verified against a live instance by running a cycle start to finish: an unnamed
cycle is numbered, an unfinished issue is carried over and a finished one is
left where it was, and the cycle filter links the two features together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #15.

Password change and "sign out everywhere" both invalidate every token including
this device's, and both hand back a replacement. Adopting it is what keeps you
signed in to the account you just secured -- there is a test that pins the old
token being dead and the new one working.

The current-password field appears only once the email address is actually
edited, because that is the single case the server demands it; asking for it
unconditionally would imply the whole form needs it.

The email toggle is disabled and explained where the instance has no SMTP
configured. Leaving it live would mean a switch that silently does nothing.

Switching instance clears the stored URL and the session, and that is the whole
of it -- the base URL is resolved per request, so nothing is rebuilt or
restarted.

Username validation mirrors the backend's pattern so the form answers rather
than a round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part of #10 -- the in-app half. Push is not built, and cannot be from here.

The issue anticipated this: push "requires a backend addition for device token
registration + push delivery". There is no /devices endpoint and nothing APNs-
or FCM-shaped anywhere in the schema, so there is nowhere to send a token. The
badge therefore polls once a minute and only while the app is in front -- a
phone that polls in the background is a phone with a flat battery -- rather than
pretending to be pushed. Recorded in the README rather than left to be
discovered.

Each of the four kinds gets its own sentence: "commented on" and "mentioned you
in" are not the same event even though both are a comment.

A notification is marked read on the way out rather than on arrival, because
opening it is what says it was seen, and navigation still happens if that write
fails -- getting to the issue matters more than the flag.

Unread is carried by a marker rather than a row background, so the list does not
change colour as things are read.

Verified against a live instance with two accounts, which is the only way to
show the acceptance criterion that your own actions notify nobody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #13.

Charts are drawn with react-native-svg, which is already here for the icon set,
rather than adding a charting library: four fixed shapes need a fraction of what
one provides, and tapping for a value is easier to get right when the hit areas
are ours.

Every number comes from the same event-history endpoints the web charts and none
is recomputed here, which is what makes the figures match rather than merely
agree. The burndown's ideal line included -- the client draws, it does not model.

A fingertip covers several days at phone width, so a tap reads the nearest point
rather than demanding an exact hit, and the readout row is reserved rather than
conditional so the layout does not jump as a finger moves.

The "history before tracking began cannot be reconstructed" notice is carried
over from web, and every chart has an empty state, because a fresh instance has
no history at all -- there is a test that the scales survive that rather than
dividing by zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sitories

Closes #16 for everything the API exposes, with one deliberate exception noted
below.

Rules render as English -- "When an issue changes status and it is unassigned,
assign it to Ada Lovelace." A rule is read far more often than it is written,
usually to work out why an issue moved, and a grid of dropdowns does not answer
that. Every lookup falls back by name, because a rule outlives the label it
points at and a sentence with "undefined" in it is worse than one saying the
label is gone.

Reordering statuses sends every id at once, as the API asks: two people dragging
columns would otherwise interleave into an order neither chose. It is driven by
arrows rather than a drag -- the board already spends its drag on cards, and a
second drag surface inside a sheet is both harder to hit and harder to verify.

Deleting a status requires naming where its issues go, so the confirm offers the
other columns rather than guessing. Guessing which column somebody's work lands
in is not a decision to make for them.

A webhook secret is not put on screen until it is asked for; these screens get
shown to other people.

Rule *authoring* is not here. A rule is a trigger plus six optional conditions
plus seven optional actions, which is a form that belongs on a wider screen --
everything else about rules (reading, enabling, disabling, deleting, the run
log) is. The panel says so and links out, as the issue allows for Jira import.

Another correction from checking: demoting the last admin is 409, not 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #17.

Only offered to an account that actually has site admin, so there is nothing to
discover that the server would then refuse.

The two guards -- you cannot deactivate or demote yourself, and the last active
site admin is protected -- are mirrored in the sheet, not to enforce them but so
the action is explained before it is attempted rather than merely refused after.
The last-admin count is a hint from one page; the server has the real answer and
is still the one that decides.

Deactivated accounts stay in the list, dimmed, because reactivating them is the
whole reason they are listed.

Setting a password exists because nothing here sends email, so the hint says to
pass it on out of band rather than implying a reset link is on its way.

Verified against a live instance, including that a deactivated account is
refused at sign-in with its own 403 rather than "incorrect password", and that
both self-guards hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #18.

React Query already knows how to pause a mutation and replay it, so the work is
telling it the truth about a phone. Its online default listens for a browser
event that does not exist here, so NetInfo drives onlineManager instead --
without that, a phone in a tunnel fires requests that time out one by one rather
than queueing anything. It watches isInternetReachable rather than isConnected,
because a captive portal is "connected" and is exactly the case that hangs.

Queued mutations are registered by key rather than passed inline. A persisted
mutation is only data, and setMutationDefaults is what turns it back into
something runnable after the app has been killed -- which is the difference
between a queue and a list of regrets.

A queued move captures the issue's updated_at when it is made, and refuses on
replay if somebody else moved the card meanwhile. Applying it blindly would undo
their work. Moving it to where it was already going is not a conflict, so that
case still applies.

Conflicts are surfaced, not resolved: the server's version and the queued one
are both somebody's intent, and choosing between them is not a decision to make
on their behalf.

The banner stays up while queued work is still going out after a connection
returns. "Back online" with three unsent changes is not the same as up to date.

Only three mutations queue -- moving a card, commenting, creating an issue.
They are the ones worth doing without a connection; everything else asks for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wak327
wak327 merged commit dea3849 into main Sep 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment