Tasks Now Talk to Your Calendar (and Discord) — A Maintenance Round
Spent today's bench shift on the Tasks system. Caught four real bugs, paid down some technical debt around the database connection config, and wired tasks into both the calendar and Discord so they actually keep you in the loop. If you've been using the Tasks board since the launch on the 24th, the things you noticed were "off" should now be fixed — and a handful of new behaviours have been added on top.
What was broken (and is now fixed)
Four bugs found during the sweep:
- Task descriptions were silently dropped on creation. You could type a beautiful description in the modal, click create, and the task would appear with everything except the description. The column existed, the edit form saved it, but the create endpoint had simply never accepted it. If you create a task today and re-edit one from the past few days to paste the description back in, both will save and persist now.
- Comment notifications went to the wrong person. The notification was being aimed at the commenter (so you got a ping about your own comment) instead of the task owner / reporter / assignees. Now it correctly notifies everyone except the commenter, and the message includes the task title.
- Voting and rating had a race condition. Two people voting at exactly the same moment could clobber each other's vote because the back-end was reading the array, mutating it in JavaScript, and writing it back without a lock. Wrapped both in a
SELECT FOR UPDATEtransaction — concurrent voters now serialise cleanly. - Reordering a column was N round-trips with no atomicity. Drag a 50-card lane and the back-end fired 50 separate UPDATE queries, none of them in a transaction. If anything failed mid-way you'd be left with a half-reordered lane. Replaced with a single UPDATE-FROM-VALUES inside a transaction. Same outcome, far fewer round-trips, atomic.
Nothing on the front-end changed for these — they'll just behave correctly from the next backend restart.
Tasks now appear on your calendar
When someone is assigned to a task, two calendar events are created automatically:
- A personal event in that user's own calendar, anchored to the task's due date (or floating today if no due date is set yet).
- A mirror event in a new shared calendar called Tasks, so the whole admin team can see the workload at a glance.
The colour tells you the state:
| Colour | State |
|---|---|
Blue #3b82f6 | Pending — incomplete, on or before due |
Green #16a34a | Completed — task moved to a closed lane |
Red #dc2626 | Overdue — past due, not completed |
The overdue scan runs every 30 minutes. So if a task's due date passes without it being marked done, the calendar event re-paints red within half an hour.
These calendar events are locked
This is one-way sync. Tasks are the source of truth — calendar events that mirror them are read-only from the calendar UI. If you try to drag, edit, or delete one of these auto-created events, the API will return 423 Locked with a message telling you to edit the task instead.
This was a deliberate design choice. Two-way sync sounds nice in theory but is a swamp in practice (timezones, recurrence, "which version is right when both moved"). One-way keeps the data model honest. If you need the due date to change, change it on the task and the calendar will follow within seconds.
The lock is enforced server-side by checking source_kind = 'task_link' on the event. User-created calendar events behave exactly as before — only the task-mirrored ones are locked.
Discord pings, but only the privacy-safe kind
The admin-notifications Discord channel now receives short, content-free pings when something meaningful happens on a task you're connected to. The format follows the existing privacy rules — names and verbs only, never the actual content of comments, descriptions, or checklist items. So you'll see who did what to which task assigned by whom, but never what they typed.
Currently wired:
| Event | Discord message |
|---|---|
| Task assigned | "@user was tagged in task 'Title'" (existing) |
| Lane move | "@actor moved a task (assigned by @owner) → IN PROGRESS" |
| Due date changed | "@actor rescheduled a task (assigned by @owner)" |
| Checklist item ticked | "@actor made progress on a task (assigned by @owner)" |
| Checklist item unchecked | "@actor unchecked an item on a task (assigned by @owner)" |
| Comment added | "@actor commented on a task (assigned by @owner)" (in-app via emit-entries) |
| Task completed | "@user completed task 'Title'" (existing) |
Every checkbox tick fires a ping. If that gets noisy we can debounce later, but the explicit decision was "fire on every tick" — visible progress is the point. If you don't want to be pinged for everything, the answer is the existing Discord per-channel mute, not silencing the system.
Bonus: a database housekeeping pass
While I was in there I noticed roughly 90 standalone diagnostic scripts in backend/scripts/ were hardcoded to host: 'localhost' with the production password baked in as a literal string — relics from before the DB2 cutover moved the database off the local box to the internal DB2 host. Most of those scripts would have failed silently or hit nothing if anyone tried to run them today.
Swept all of them: 80 scripts had their bare host: 'localhost' and hardcoded password replaced with process.env.DB2_HOST and process.env.DB2_PASSWORD, with a require('dotenv').config() injected at the top of each. A second pass over backend/services/ and backend/routes_v1p/ stripped the || '77Dizzle!' fallback that was lurking on already-env-aware password lines (a security smell — production passwords don't belong in source code, even as fallback). 116 files updated in total.
The active services were already using env vars; they keep working unchanged. The diagnostic scripts now actually work on this machine — and the production password is no longer sitting in the codebase as plaintext.
The agent helper header (admin/tasks/helpers/00_task_helper_header.md) was also updated with two new sections so future agents stop walking into the same traps:
- Auth User ID — the canonical JWT claim for "who is this request" is
req.user.sub. Notid, notuser_id, notuuid. Those don't exist on our payloads — agents had been writing defensive fallback chains that resolve to nothing. - Admin-team identity — the canonical "is this person on the admin team" check is the
core_source.staff.is_adminboolean, not theuser_role = 'admin'enum (which is overloaded and unreliable for that question). The header now shows the canonical SQL pattern and the YP3000 resolution chain for inbound channel handlers.
Quick reference
| Thing | What changed | Where |
|---|---|---|
| Task description on create | Now actually saves | routes_v1p/admin-tasks.js |
| Comment notification | Targets owner / reporter / assignees, not the commenter | routes_v1p/admin-tasks.js |
| Vote / rate concurrency | Race-safe via SELECT FOR UPDATE | routes_v1p/admin-tasks.js |
| Lane reorder | Single transaction, single round-trip | routes_v1p/admin-tasks.js |
| Personal calendar event | Auto-created on assignment | services/task-calendar-sync.js |
| Shared "Tasks" calendar | New, seeded by migration | admin_calendar.calendar |
| Calendar lock | 423 Locked on PATCH/DELETE for source_kind='task_link' | routes_v1p/admin-calendar.js |
| Overdue colour scan | Every 30 minutes | services/task-calendar-sync.scanOverdue |
| Discord progress pings | Lane move, due-date change, checkbox tick, comment | services/discord-notify.notifyTaskProgress |
| DB host / password sweep | 116 files moved to env vars | backend/scripts/, services/, routes_v1p/ |
Migration applied: 20260430_task_calendar_sync.sql — adds the link-table columns, the source_kind event tag, and seeds the shared "Tasks" calendar.
Restart the backend, drag a card, watch your calendar paint up. If anything misbehaves, ping me — I'm at the bench tomorrow too.
— Henry