A heroic cycle. The user walked in with a Project Handoff Document
that quietly torched our existing audience strategy: forget enterprise
teams, build for the actual humans nobody else builds for — students,
freelancers, event planners, anyone with a checklist and no patience
for sprint ceremonies. New product philosophy: "ultra-low barrier to
entry, zero friction, high dopamine." Five phases. Then mid-cycle they
asked for two more things on top — per-task conversation history, and
an /about page that reads like a manifesto. All seven shipped in one
autonomous run. Buckle up.
Phase 1 — Choose-your-adventure landing + actually useful empty states
The pitch had to land instantly: this tool is for you, whatever you
do. We built four domain "packs" (marketing, student, freelance,
event) in src/lib/domains.ts. Each one overlays the canonical
16-task seed structure — same task IDs, same lane positions, same
timeline geometry, so the cinematic demo's scripted scenes still
work — but swaps every visible surface: titles, tags, workspace name,
URL chrome, seed comment bodies, even an empty-state headline. Click
"College student" and the demo's "Audit pricing page conversion
funnel" becomes "Submit thesis proposal." Click "Event planner" and
suddenly the workspace is "Hartwell Wedding · 6.14.26."
The new <DomainToggle> (marketing) is a pill row with a
LayoutGroup-driven sliding indicator. The demo container is keyed by
domain so AnimatePresence cross-fades between flavors over 360ms —
the swap feels like a domain change, not just a string update.
cinematic-demo.tsx accepts a domain prop now; what used to be
hardcoded scene references ("Audit pricing page", "Latest features
email", "Sales sync → Demo video") now look up the live task title
at scene-fire time, so the activity feed reflects whichever domain
is active.
Backend earned its keep: seedDomainAction(domain) in
src/server/actions/seed.ts truncates tasks/comments/activities,
re-seeds with the overlay, and stamps the domain choice into a new
meta key-value table. clearAllTasksAction is its symmetric twin.
The hero "Try this template in your workspace" button calls it and
routes the user straight into /app/board — they end the click on
their own data, not a demo.
The persisted domain choice flows through a new <DomainProvider>
(src/lib/domain-context.tsx) mounted in /app/layout.tsx.
AppPageHeader and <AppSidebar> consume useDomain(), so the
workspace breadcrumb, H1, and the user-team pill all flex with the
chosen domain. Seed event-domain data → header reads "Events ›
Hartwell Wedding · 6.14.26" and the sidebar account chip says "David
Park / Events." It feels like one product across four lives.
Empty states got the same care. The previous behavior on a freshly-
cleared workspace was a depressing blank canvas. Now we render
<EmptyStateOverlay>: a faded structural ghost of the actual view
behind a radial-fade overlay, with a headline ("This is where your
master plan goes."), a body, the primary "Add your first task" CTA
keyboard hint, and four inline starter-pack chips that fire
seedDomainAction directly. The four ghosts (<BoardGhost>,
<ListGhost>, <TimelineGhost>, <CalendarGhost>) are tiny
silver-pencil sketches of their respective views, filled with
placeholder rows / cards / bars / cells in low-opacity neutrals.
Empty doesn't mean nothing-to-show; empty means "here's what this
will look like the moment you start."
Phase 2 — Killing the jargon, building the dopamine
The directive was clear: cut enterprise vocabulary. The audit was
mercifully short — most of the app already speaks plainly. We caught
two stale code comments (panel-header.tsx calling task IDs "ticket
ids", demo-surface.tsx calling the timeline a "gantt") and cleaned
them up. App-level labels were already in good shape; the only
"Sprint" / "Epic" hits were inside seed task titles, which are user
content, not labels. We left those alone — a real freelancer might
genuinely have "Sprint review w/ Bramwell team" on their plate.
Then the fun part. <DopamineCheck> (src/components/app/done-dopamine/)
is the new completion primitive — a round button (round, not square;
roundness is what makes the pop feel like a click). On the
open→done transition: spring-pop scale 1→1.18→1, the fill flips green,
the ✓ glyph stroke draws in over 220ms, and a six-dot radial burst
(alternating emerald and brand) explodes outward and fades over
620ms. Self-contained. No portal. No framework wizardry. It just
feels good.
<DoneTitle> is its quieter partner — animates the title color from
--ink to --ink-quiet and draws a left-to-right strikethrough via
scaleX (CSS text-decoration famously refuses to animate, so we
fake it with a 1.2px gradient line). Both run in lockstep on the
click. Wired into list-app and my-tasks-app rows; the old inline
checkboxes are gone.
Phase 2.5 — Conversation history (mid-cycle user request)
User mid-cycle: "i also want to add conversation history to each
task." The detail panel had been rendering Activity and Comments as
two separate stacked sections. We collapsed them into one
chronological feed — Linear/GitHub-style — because that's what
"conversation history" actually is.
New getTaskConversation(taskId) in queries.ts unions comments
activities as a discriminated ConversationItem union and
sorts by createdAt ascending. New <ConversationFeed> renders
comments as full quoted blocks (22px avatar, name + relative time,
body) and activity rows as one-line system messages (14px avatar +
sentence, quieter color). Composer at the bottom. Optimistic
add/remove with temp- ids; reconciles after the server response.
The cleanup was satisfying. Old <CommentThread> and <ActivityFeed>
detail-panel files deleted. Their useTaskComments /
useTaskActivities hooks deleted. The getActivitiesForTaskAction
server action deleted. Pure subtraction, no compat shim — exactly the
kind of cleanup the user's preference forbids backwards-compat hacks
for.
Phase 3 — Zero time-to-capture: writing English instead of filling forms
Installed chrono-node and built parseTaskInput(raw) in
src/lib/nlp/parse-task-input.ts. The parser uses
chrono.parse(input, new Date(), { forwardDate: true }), takes the
rightmost match (so trailing "by next Friday" forms keep the leading
verb intact), and strips the date span plus a preceding
"by"/"on"/"at"/"due"/em-dash lead-in. Returns
{title, dueAt, dueLabel}. The companion formatDueLabel(d, withTime)
produces tight chip-friendly text: "Today" / "Tomorrow" / "Fri 3pm" /
"Mar 14" / "Mar 14, '27." Tabular nums, no jitter.
Schema gained a structured tasks.due_at timestamp column running
parallel to the existing human due text label. Task.dueAt?: Date.
addTaskAction accepts and persists both. The structured datetime
unlocks the digest cron's "due in next 24h" filter (Phase 5) without
re-parsing the human label every read.
<QuickCreateDialog> parses on every keystroke. The moment chrono
detects a date phrase, an inline preview pill slides in beneath the
input with the cleaned title and a brand-soft "Due May 15 3pm" chip.
The user sees what the parser is going to do before they press
Enter — no surprise, no "oh wait, that's not the title I meant." The
dialog placeholder is also dynamic now, pulling
pack.firstTaskExample from the active domain ("Submit thesis
proposal by next Friday" for students, "Send Q2 invoices to all
clients tomorrow" for freelancers). <InlineComposer> (per-lane)
runs the same parse on submit with a smaller chip-only preview.
Verified end-to-end: typed "Finish thesis draft by next Friday at
3pm" → preview pill rendered "Finish thesis draft" + "Due May 15
3pm" → submitted → DB row landed with title="Finish thesis draft",
due="May 15 3pm", due_at=1778853600 (= May 15 at 22:00 UTC, which
is 3pm Pacific). Chrono is doing actual work.
Phase 4 — Magic Link guest sharing
This phase is the one the marketing engine will love. Schema gained
a share_links table (token PK, view, createdAt, optional revokedAt)
and createShareLinkAction(view) mints a 16-char URL-safe token. The
read-only resolver resolveShareLink(token) lives in queries.ts
(server-only — not an RPC, since /share/[token] fetches it during
SSR).
The header <ShareButton> opens a popover. First click "Generate
magic link" mints a fresh token, then renders the URL with a
copy-to-clipboard button (a 1.1-second "Copied" emerald flash on
success) and a "Preview as guest" link that opens the share URL in a
new tab. The token is durable per-session; "revoke" lives as a
backlog item.
The /share/[token] route renders the workspace as a guest sees it:
no sidebar, minimal top chrome with the workspace breadcrumb, a
"Read-only · Shared link" pill, and a "Make this yours" CTA back to
/app/board. <ShareBoard> is visually identical to <BoardApp>
minus the drag handlers and the inline composer.
The cleverer bit is <GuestAuthProvider> + useGuestAuth. Every
interactive surface on the share view (task card click, "Add task"
button, the implicit edit affordances) routes through
promptSignUp(reason). When isGuest is true, this raises a
reason-aware modal with stubbed "Continue with email" / "Continue
with Google" buttons + a "Keep browsing as a guest" dismissal.
Reasons: edit | comment | addTask | complete | share. Outside guest
context the hook short-circuits to a no-op so the same call sites
are safe in /app/* without conditional logic. Same component tree,
two modes.
Verified: clicked Share → Generate → got
http://localhost:3001/share/61c1c32269f94f2c. Visited as guest:
read-only board with the workspace chrome, no sidebar. Clicked "Add
task" — progressive auth modal popped with the right copy: "Sign up
to add tasks. You're viewing a shared workspace…"
Phase 5 — The anti-notification engine (or: how to not spam the user)
This was a stance, not just a feature. Schema gained a notifications
table with id PK, userId, kind, optional taskId FK (cascade), JSON
payload, createdAt, optional readAt. The Notification type's
discriminated union covers mention | blocked | dueToday.
The policy is enforced in code, not configuration:
src/server/db/notifications.ts exposes notify(userId, payload),
server-only, self-mentions skipped (the smartest people still
forget this).
addCommentAction is the only mutation that calls notify. It
runs extractMentions(body) — a regex that catches @<word>,
lowercases it, and filters against the canonical USERS set so
random @stuff doesn't fire spurious pings. For each valid
mention, one notification row.
moveTaskAction, toggleCompleteAction, updateTaskAction,
addTaskAction, removeTaskAction — none of them call notify.
Lane moves, status flips, simple field edits produce activity
rows (already in the conversation feed) but never pings. Comments
without @mentions also produce nothing. They live in the
conversation feed and surface in the daily digest only if the
recipient was tagged.
The other half is compileDailyDigest(userId) in
src/server/db/daily-digest.ts — a pure read function returning
{ completedYesterday, dueToday, mentions }. dueToday filters by
assignees LIKE '%"<userId>"%' AND dueAt between now and
now+24h. mentions scans the activity log for commentAdd snippets
containing @<userId>. While we were here we hoisted rowToTask
out of queries.ts into a shared src/server/db/row-mappers.ts so
the digest can reuse it without a circular import.
The new /app/inbox route renders the policy as UI. Two surfaces:
- Daily digest — two cards (Closed yesterday / Due today) plus a
brand-soft "Mentioned in the last 24h" callout when present. The
preview is exactly what the morning email would send; no second
source of truth.
- Direct alerts — list of unread instant pings, or — 95% of the
time — the empty state: "Inbox zero. Quiet here on purpose." with
a bell glyph. The copy reinforces the policy: "We only insert
here for direct @mentions and blocks. Lane moves, status flips,
simple edits — none of it. Read once, move on."
/api/cron/digest returns the digest as JSON for any external
scheduler (Vercel Cron, GitHub Actions, a CI job). ?user=<id>
overrides default CURRENT_USER for testing. The contract: this is
the ONLY scheduled outbound channel. When email actually wires up,
the swap is at the vendor edge — the policy decisions stay here, in
code.
AppPageHeader learned about inbox: breadcrumb "Personal › Inbox",
title "Inbox", Share button hidden, view tabs hidden — inbox isn't
a workspace view, it's its own thing.
End-to-end verification was the satisfying part. David posted
@chloe can you double-check the run-of-show timing? on t-202. DB
inspection confirmed exactly one notifications row inserted —
user_id=chloe, kind=mention, payload with the snippet, taskId,
from, taskTitle. GET /api/cron/digest?user=chloe returned a digest
with mentions: [{ from: "david", taskTitle: "Day-of timeline · run-of-show", snippet: "@chloe can you…" }]. Zero notifications for
all the lane moves and toggle-completes that happened during testing.
The dam holds.
Phase 4.5 — About manifesto (mid-cycle user request)
User mid-cycle: "i also want an about us link in the footer that
talks about how project management dodesnt need to be behnind a
paywall or a knowledge gap, wherther its sprints/epics issues etc we
cut out all that bullshit and make project management accesible to
everyone."
We were ready for this one — the whole cycle had been building toward
it. New /about route + <AboutManifesto> component. The hero says
the quiet part loud: "Project management shouldn't be behind a
paywall." The visual centerpiece is a card listing ten enterprise PM
phrases — sprint planning, epic refinement, ticket triage, gantt
cascades, OKR alignment, the whole liturgy — each animating a
left-to-right strikethrough on scroll-into-view, with tasks
rendered as the surviving emerald chip on the right. It reads as the
manifesto in three seconds.
Three body sections follow: "You don't need a vocabulary. You need a
list." (the thesis). "Built for whoever shows up." (renders the four
domain packs as cards — same content surface, different
presentation). "What we promise." (numbered: free where it counts,
no vocabulary tax, looks like the work, out of your way). Closes
with a "Plain English" callout: "Write down what you have to do.
Look at it the way that helps. Cross it off. That's the whole
product." and a CTA to /app/board.
Footer "About" link rewired from # to /about.
End-to-end verification (Playwright + DB inspection)
- Domain toggle on landing: clicked College student → demo workspace
title became "Spring semester · Junior year", URL chrome became
"tasks.app/me/school", all 16 task titles became student-flavored,
comment thread typed "Group's meeting at the library tonight at
7 📚", activity feed references all updated.
- "Try template" CTA: seeded the DB with student data, routed to
/app/board, board rendered with student tasks live.
- Empty states: SQL-truncated tasks, navigated to all four views —
each rendered the faded ghost overlay + headline + starter-pack
chips. Clicking "Event planner" on the calendar empty state
reseeded the DB with wedding-flavored data; calendar immediately
filled with vendor sync / catering / florals etc.
- Page header & sidebar: pulled from the persisted
meta.activeDomain row. Workspace H1 read "Hartwell Wedding";
sidebar pill said "David Park / Events" after seeding the event
domain. The whole shell flexes.
- Done Dopamine: clicked checkbox on a list row — task moved to
Done lane, count incremented, button aria-pressed flipped to
true. Re-opened the now-done task; conversation feed showed the
new "DV David marked this complete · just now" activity row
interleaved beneath the three seeded comments.
- NLP date parse: typed "Finish thesis draft by next Friday at 3pm"
→ live preview pill ("Finish thesis draft" + "Due May 15 3pm") →
submitted → DB confirmed
title="Finish thesis draft", due="May 15 3pm", due_at=1778853600.
- Magic link: clicked Share → Generate → URL minted. Visited as
guest: read-only board, no sidebar, "Read-only · Shared link"
pill in the header. Clicked "Add task" — progressive auth modal
popped with reason-specific copy.
- @-mention: David posted "@chloe can you double-check the run-of-
show timing?" on t-202.
notifications row appeared
(user_id=chloe, kind=mention). GET /api/cron/digest?user=chloe
surfaced the mention. No notifications for any of the lane moves
or toggle-completes that happened in testing.
- About:
/about rendered with the strikethrough block animating
on scroll-in, four domain cards, four numbered promises, and
the closing CTA card.
- 0 TS errors, 0 console errors throughout.
What we deleted (unprompted but earned)
src/components/app/detail-panel/comment-thread.tsx
src/components/app/detail-panel/activity-feed.tsx
src/lib/tasks/use-task-comments.ts
src/lib/tasks/use-task-activities.ts
src/server/actions/activity.ts
All five replaced by the unified Conversation feed. No deprecation
period. No "// removed" comment. They're just gone.
Backlog (the cuts made under deadline)
- Real auth replaces
CURRENT_USER. The magic-link auth modal's
"Continue with email" / "Continue with Google" buttons currently
just dismiss — they'll wire to a real provider later.
- The digest endpoint is JSON-only; an actual cron scheduler +
transactional email integration is the swap-in. The policy is
here; the delivery is not.
compileDailyDigest.completedYesterday is currently team-wide.
Fine for a 5-person workspace; spammy at 50. Future cycle:
narrow to "tasks the user touched."
- Multi-tab realtime sync (cycle 5 backlog) still pending.
- Per-lane draft preservation in QuickCreateDialog (cycle 7
backlog) still pending.
- The
meta table is currently a single key/value bag. If it
picks up more keys (notification config, share defaults), it
gets a typed accessor layer.
Notification.kind = "blocked" is wired in the schema and type
but never inserted — waiting on the dependency UI to migrate
from the cinematic demo into the live app.
- Magic-link revocation: minted tokens are durable forever right
now. UI for "revoke this link" is one cycle away.