
PostgREST returns HTTP 200 when it silently truncates a result set at 1,000 rows. The truncation is documented; the response code stays 200. There is no error to catch. That behavior was the first constraint Taskline was designed around, and it shaped a lint gate, a chain-aware query scanner, and a response flag that tells callers when their list is partial — before any visible feature of the product existed. We built a team project management tool. The decisions that made it what it is mostly came from constraints like that one.
Starting point
The category reference was structural: the board/column/card model that board-based PM tools established as a spine, and the multi-view idea — same task set presented as board, list, or calendar — that the multi-view class of team PM carries. Not the enterprise layers. Not the integrations. The shape of the thing at the team level.
The core bets from day one: one task model, three views over it. Board, list, and calendar share the same query. The URL structure reflects the relationship directly — /projects/[id]/board, /projects/[id]/list, /projects/[id]/calendar — and the data layer has no opinion about which you open first. Multi-tenant from the start, each team an isolated workspace. Flat per-organization pricing, not per-seat: the team's cost is fixed before you know your headcount.
One board per project in v1. That decision is in the schema — board_columns carries a direct project_id foreign key, no intermediate boards table. Multi-board per project is a future consideration, not a gap we papered over.
Reading the market
The gap Taskline targets is in pricing structure, not in feature inventory.
Atlassian repositioned Trello around individual task management in May 2025 — their own announcement, corroborated independently. Team boards still exist in Trello; the product still sells team plans. What changed is that the small-team project management use case is no longer the center of gravity. Trello carries integrations and a view range that Taskline does not; the shift is in where the development focus landed, not in what the product lists.
Asana's free tier now allows only 2 collaborators for shared work — that limit is stated on Asana's own pricing page. Asana covers workflow complexity and reporting depth that Taskline does not; the gap is the free-tier ceiling for small teams with three or more people trying to share a board.
The honest version of the pricing gap: flat organizational pricing for team PM is real but thin. Basecamp is the mainstream name that has run it successfully. The rest of the category is per-seat — rented monthly, with costs that compound as headcount moves. Taskline's flat model is not cheaper for teams of two. It is predictable for teams that grow, and it does not require re-forecasting when someone joins.
The decisions that shaped it
Every query that can return multiple rows carries a .range() call. The response carries a truncated flag when it fires.
PostgREST's silent 200-OK truncation leaves no error to catch. We enforced .range() as a lint gate, but the naïve grep — looking for from('tasks') paired with .range( on the same line — turned up six false positives immediately. Idiomatic query chains don't work that way: the builder identifier travels through assignment, filter clauses, and conditionals before .range() appears. We replaced the grep with a chain-aware scanner that follows the builder identifier through the file and balances brackets. Final measurement before the gate was trusted: 31 from('tasks') chains audited, zero unbounded. The truncated flag was a second decision inside the same constraint: a bounded endpoint that does not tell its caller it is bounded trades one silent truncation for another.
Fractional positions, with id as a stable secondary sort key and a weekly cron that never touches the hot path.
Card order is stored as (prev+next)/2 — a float that halves the gap on every drag. The weekly cron renumbers the column when the float exhausts; the drag path never triggers it. The failure mode this arrangement produces is a midpoint collision: two clients dropping cards into the same slot at the same moment compute the same midpoint. We measured it rather than theorized it — four ordinary drags produced two DONE cards both at position = 500, and Postgres ordered the tie arbitrarily, so two clients rendered the same board in different order and never reconciled. Adding id as a secondary sort key costs nothing and closes the tie deterministically. Any fractional-position board wants that tiebreaker from day one.
Live boards over a direct client WebSocket. On socket loss, the board degrades to polling and the badge says so.
Serverless functions held open per viewer are how a prior product's simultaneous users took the site down. Taskline uses Supabase Realtime Broadcast — a client-direct WebSocket on a channel keyed to the project ID, consuming zero serverless connection budget. Server broadcast fires after the DB write, not before. When the socket dies, the board degrades to polling. We verified that state with the socket forcibly killed: the badge read Polling across six samples, drags completed and persisted to the remote database. A polling fallback you have not killed and checked is a statement of intent. This one is a measured outcome.
Guest access goes through SECURITY DEFINER helpers, not inline subqueries against the tables the policies protect.
A RLS policy that subqueries the table it protects recurses. Two SECURITY DEFINER functions — is_project_member() and is_task_accessible() — let any policy ask the project-access question once, cleanly. They are fully schema-qualified, their search_path is pinned, and EXECUTE is granted only to authenticated and service_role. We measured the guest boundary live: a guest sees the one project they were invited to; a search returning results for a member returns zero for the guest. The data layer is correct, and its correctness is a measured count, not an inference from the RLS configuration.
The two screens a team opens every morning are each a single round trip.
The board fetch runs two queries in parallel — columns and tasks — and composes them in the response. The dashboard aggregate is a single CTE function that returns one JSON object per role, probed live per role before the phase closed. Sequential fetches on the board and the dashboard are felt. The single round trip is designed in, not optimized after the fact.
What fought back
The calendar library was selected in research, passed its license check, and turned out to be unusable in practice. Four MIT-licensed packages from the schedule-x family installed cleanly — license verified in both the LICENSE file and package.json. The peer graph was not verified. The core is a Preact bundle, the drag-and-drop plugin targets a different major version of the same API, and five unresolvable peer conflicts surface immediately on install. We built the calendar grid from the bespoke seven-column layout in the design HTML and used @dnd-kit/core for drag. The calendar ships and works. The schedule-x packages remain in package.json, imported by nothing. The lesson is not about that library — it is that a license check is not a dependency check.
The board avatar bug was thorough. fetchBoard() built its member list from project_members rows alone. Organization-level members have no such row — their membership is implicit. Every assignee without an explicit project-membership row fell through to a UUID-fragment initializer. Because every demo UUID starts a1b2c3d4, every unresolved avatar rendered the same two letters: A1. Not a random UUID fragment — literally the same two characters for every assignee, making every assigned task look like it belonged to the same person. The correct union — org members merged with project-member rows, project row winning on conflict — already existed in the members endpoint. fetchBoard() simply never called it. After the fix: five members resolved from one, three unresolved assignees from zero.
The honest v1 edges: board automations, CSV import, and API access are available on paid plans, not on the free tier. The i18n layer has no pluralization — count interpolation is raw throughout, so "1 projects · 1 active" appears in the live output and is a systemic fix, not a one-line patch. Guest route protection is enforced client-side; the data boundary is measured correct (a bypassed redirect yields an empty shell, not a data leak), and moving the enforcement server-side is the next hardening step.
What shipped
A complete multi-tenant team project management build: three views over one task model, rich tasks with assignees, labels, due dates, checklists, comments, mentions, and attachments. Workspace search and a dashboard that returns a single-query aggregate per role. Project setup templates that build column and label structure. Guest collaborators scoped to a single invited project. Live boards with a verified polling fallback. Row-level security on every table, with a measured data boundary per role.
The admin plane for the instance owner is isolated from the tenant application shell. Board automations, CSV import, and API access are available on paid plans.
The product is priced at $199 — a one-time purchase, flat for the team.
