Repository navigation
Land the mobile app: issues #3–#18 - #37
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.maincurrently contains only the foundation (#1) and login (#2).This is the integration PR that actually lands the rest.
mobile-offlineis the top of the stack and a strict superset ofmain; the merge is clean.Consequence worth knowing: GitHub's
Closes #Nkeyword only fires on merge to the default branch, which is why #3–#18 are all still open despite their PRs being merged.What lands
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.