diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/SubtaskDeletedIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/SubtaskDeletedIntegrationEvent.cs new file mode 100644 index 00000000..dec2373f --- /dev/null +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/SubtaskDeletedIntegrationEvent.cs @@ -0,0 +1,35 @@ +namespace Planora.BuildingBlocks.Application.Messaging.Events +{ + /// + /// Raised by TodoApi when a single subtask is deleted. A subtask owns no branch of its own — + /// its only timeline footprint is the system comments it left in the PARENT task's branch + /// ("X added a subtask: …" / "X completed a subtask: …"). Collaboration consumes this and + /// soft-deletes exactly those announcement comments so the parent's branch stays clean. The + /// subtask title is carried so the consumer can match the deterministic sentence suffix + /// (the comment store keeps no structural link back to the subtask aggregate). + /// + /// Note: deleting a whole parent task instead emits , + /// which removes the entire branch (including any subtask announcements) in one shot. + /// + public sealed class SubtaskDeletedIntegrationEvent : IntegrationEvent + { + /// The parent task whose branch carries the announcement comments. + public Guid ParentTaskId { get; init; } + + /// The deleted subtask's id (diagnostic / future correlation). + public Guid SubtaskId { get; init; } + + public Guid ActorId { get; init; } + + /// The deleted subtask's title — matches the system-comment sentence suffix. + public string Title { get; init; } = string.Empty; + + public SubtaskDeletedIntegrationEvent(Guid parentTaskId, Guid subtaskId, Guid actorId, string title) + { + ParentTaskId = parentTaskId; + SubtaskId = subtaskId; + ActorId = actorId; + Title = title; + } + } +} diff --git a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs index 06aca1d9..8b5853cb 100644 --- a/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs +++ b/BuildingBlocks/Planora.BuildingBlocks.Application/Messaging/Events/TaskActivityIntegrationEvent.cs @@ -10,13 +10,19 @@ public static class TaskActivityType public const string Completed = "Completed"; public const string StartedWorking = "StartedWorking"; public const string Left = "Left"; + + // Subtask lifecycle surfaced in the PARENT task's branch (TaskId = parent id; + // Detail = subtask title). + public const string SubtaskCreated = "SubtaskCreated"; + public const string SubtaskCompleted = "SubtaskCompleted"; } /// /// Raised by TodoApi on task lifecycle transitions (owner completes/starts/stops, worker - /// joins/leaves). Consumed by the Collaboration service to append a system comment to the - /// task's activity timeline. The sentence template lives in the consumer; this event only - /// carries the structured fact plus the actor's display name (freshest at publish time). + /// joins/leaves) and subtask create/complete. Consumed by the Collaboration service to append a + /// system comment to the task's activity timeline. The sentence template lives in the consumer; + /// this event only carries the structured fact plus the actor's display name (freshest at + /// publish time) and an optional (e.g. the subtask title). /// public sealed class TaskActivityIntegrationEvent : IntegrationEvent { @@ -27,12 +33,16 @@ public sealed class TaskActivityIntegrationEvent : IntegrationEvent /// One of . public string ActivityType { get; init; } = string.Empty; - public TaskActivityIntegrationEvent(Guid taskId, Guid actorId, string actorName, string activityType) + /// Optional context for the sentence, e.g. the subtask title. Null for plain task events. + public string? Detail { get; init; } + + public TaskActivityIntegrationEvent(Guid taskId, Guid actorId, string actorName, string activityType, string? detail = null) { TaskId = taskId; ActorId = actorId; ActorName = actorName; ActivityType = activityType; + Detail = detail; } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index beb7aecc..0ba1df2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,269 @@ All notable changes to Planora are documented here. Format follows [Keep a Chang ## [Unreleased] +### feat(subtasks): per-user in-work with a worker count; hide all subtask system lines (2026-06-03) + +**Per-user "in work" with a count.** Taking a subtask into work is no longer a global status — +it's now **per-user**, like the parent task's worker model: + +- Frontend `toggleSubtaskWork` calls `joinTodo`/`leaveTodo` (not a status write); each participant + joins/leaves independently. One person working never flips it "in work" for another. +- Every viewer sees an anonymous **"N working"** presence badge (`workerCount`); the viewer's own + membership (`isWorking`) shows as "You're working" / "You + N working" and drives their toggle. +- Backend: the **owner-always-worker rule is relaxed for subtasks** (`TodoItem.AddWorker`), so the + owner opts in like everyone and is counted; `JoinTodo`/`LeaveTodo` let the owner join/leave a + subtask and **skip the "started working"/"left" activity events for subtasks** (no naming, no + branch noise); `GetSubtasks` reports `IsWorking` from worker membership (owner included). Subtasks + have unlimited worker capacity (`RequiredWorkers` is null). The live-merge now refreshes on + `workerCount`/`isWorking` changes so other users' counts update without re-opening the modal. + +**No stray "completed a subtask: " lines.** `buildFeed` now hides **every** subtask system +comment (`added`/`completed a subtask:`) from the rail — matched or not — so legacy/renamed/orphaned +ones never reappear as standalone nodes. The folded completion reply text changed to +**"{Name} completed sub task"** (nameless fallback "Sub task completed"). + +Tests: +1 domain test (`AddWorker_OnSubtask_AllowsOwner`); existing worker/handler suites green +(53 in the touched sets). Frontend 393 vitest green; `tsc`/`eslint` clean; `npm run build` ok. + +### feat(subtasks): anyone can take a subtask into work; all viewers see it (2026-06-03) + +- **Taking a subtask into work is now global**, like completion. The frontend `toggleSubtaskWork` + no longer requires ownership (the backend already authorised any participant to set a subtask's + status), and the **Zap "take into work" toggle is shown to every viewer** on hover (editing and + delete stay owner-only). So when *any* user picks up a subtask, the others see it. +- Polished the **"In progress" presence badge**: it now animates in/out (spring), uses a soft amber + gradient pill with a pulsing dot, and is shown **to every viewer** on the card — it **never names + who** is working (hover title: "Someone is working on this"). Derived from the subtask's live + `status` (polled), so it appears for other users without re-opening the modal. +- Frontend only; `tsc`/`eslint` clean; 393 vitest green; `npm run build` ok. + +### feat(subtasks): no creation notice, icon-less completion reply on a sub-branch (2026-06-03) + +- **No "added a subtask" notification anywhere.** `CreateSubtaskCommandHandler` no longer enqueues + the `SubtaskCreated` activity event (dropped its outbox dependency), and the frontend hides any + legacy creation comments and renders no creation caption. The subtask card just appears. +- **Completion is an icon-less reply on the subtask's sub-branch.** The "completed a subtask" system + comment is still posted to the parent branch but is never a standalone rail node — it renders as a + reply hanging off the subtask via a soft "└" elbow, with **no rail icon/dot**, just + "**{Name}** completed this · HH:MM". +- **Subtask system notifications carry no rail icons** — the only marker on the sub-branch is the + subtask's own completion toggle, kept at the card's vertical centre. +- Polished the "reply"/branch visuals: a fork from the main rail to the toggle, a stem to the offset + card, and a state-tinted sub-branch that continues down into the completion reply. +- Tests: `CreateSubtask` handler now asserts **no** outbox event (`Times.Never`) and the handler + drops its `IOutboxRepository` dependency. Backend touched suites green (42); frontend 393 vitest + green; `tsc`/`eslint` clean; `npm run build` ok. + +### feat(subtasks): branch the subtask cluster off the rail (2026-06-03) + +Refined the subtask cluster so it reads as a proper offshoot of the branch. All frontend +(`edit-todo-modal/branch-feed.tsx`). + +- The whole cluster (creation caption, card, completion reply) is now **offset to the side** + (`SUBTASK_OFFSET`) and joined back to the rail by short, state-tinted connectors — it clearly + branches off like a reply, instead of sitting flush like a normal message. +- **Every subtask line keeps its own dot on the rail**, on par with other timeline events: a small + indigo "added" dot for the creation caption, the completion toggle for the card, and a green node + for the completion reply. +- The **completion toggle (the subtask's rail icon) is now vertically centred on the card** rather + than pinned to the top — so tall/wrapped cards stay visually balanced. +- Dropped the now-redundant in-card list glyph (the rail toggle is the subtask's icon). The + completion reply node is likewise centred on the rail with its note offset to the side. +- `tsc`/`eslint` clean; 393 vitest green; `npm run build` ok. + +### feat(subtasks): fold lifecycle events into an integrated card cluster (2026-06-03) + +Reworked how subtask system notifications appear in a task's branch so they feel native to the +thread instead of scattered rail nodes. All frontend (`edit-todo-modal/branch-feed.tsx`); the +backend events/templates are unchanged. + +- **Folded the create/complete system comments into the subtask card.** `buildFeed` matches each + `"… added a subtask: <title>"` / `"… completed a subtask: <title>"` comment to its subtask by the + `: <title>` suffix, parses the actor name into a `SubtaskMeta`, and **hides them as standalone + rail nodes**. The subtask now renders as one cluster: + - a **minimalist creation caption** — "**{Name}** added a subtask · HH:MM"; + - the card (toggle = rail marker); + - a **completion "reply"** the rail gently bends down to (curved connector → green check node → + "**{Name}** completed this · HH:MM"). On optimistic completion a nameless "Completed" shows + instantly, then the name fills in when the folded comment lands. +- **In-progress visible to everyone.** Taking a subtask into work shows an amber "In progress" pill + with a pulsing dot to *all* viewers (derived from the live `status`, no name), replacing the old + owner-centric "Working" badge. +- **Deletion still clears everything** — the folded comments are removed optimistically (suppressed + ids) and server-side via the existing `SubtaskDeletedIntegrationEvent` cascade. +- New `SubtaskCompletionReply` component + curved SVG connector; spring enter/exit animations + throughout. `tsc`/`eslint` clean; 393 vitest green; `npm run build` ok. + +### fix(subtasks): schema-qualify the Title column widening (500 on long subtask) (2026-06-03) + +The startup column-widening shipped in the 1500-char change targeted an unqualified `"TodoItems"`, +but the table lives in the **`todo` schema**. The `ALTER TABLE "TodoItems" …` threw +`42P01: relation "TodoItems" does not exist` (swallowed as a non-fatal warning), so the column +stayed `varchar(200)` and creating a subtask with a >200-char title failed with a Postgres +`22001: value too long` → HTTP 500. Fixed the statement to `ALTER TABLE todo."TodoItems" …` and the +`information_schema` guard to filter `table_schema = 'todo'`. The widening was also applied directly +to the running database, so existing local installs work without a rebuild. + +### fix(tasks): keep the Quick Filter plate after creating a task (2026-06-03) + +The Quick Filter plate on `/tasks` vanished after creating a task through the create panel. The +filter bar and the create panel shared one `AnimatePresence mode="wait"` swap; `handleCreate` flips +`isCreateOpen` and then immediately re-renders via `fetchActiveTodos` (`setLoading`), which +interrupted the deferred enter and left the filter collapsed at height 0. They are now two +independent `AnimatePresence` presences, so closing the create panel (on submit or via Close) +always re-reveals the filter. Frontend `tsc`/`eslint` clean; 393 vitest green; build ok. + +### feat(subtasks): allow up to 1500-character subtask titles (2026-06-03) + +A subtask's whole content lives in its title, so subtasks now accept **up to 1500 characters** +(regular-task titles stay ≤200). Updated every layer: `CreateSubtaskCommandValidator` (200→1500), +`UpdateTodoCommandValidator` (200→1500, since subtask renames share `PUT /todos/{id}`), the EF +`TodoItems.Title` column (`varchar(200)`→`varchar(1500)`), and the frontend `SUBTASK_MAX` +(200→1500). The inline subtask editor became an auto-growing **textarea** so long titles are +comfortable to edit; regular `CreateTodo` titles remain capped at 200. + +- **DB reconciliation:** the Todo service runs an idempotent, metadata-only `ALTER TABLE + "TodoItems" ALTER COLUMN "Title" TYPE varchar(1500)` at startup (guarded by an `information_schema` + check) so existing migration-built databases get the wider column without a committed migration; + fresh installs get 1500 straight from the EF model. +- Tests: EF model-config (1500), Todo validator (subtask 1500 accept / 1501 reject; update path + 1500), and the error-handling integration update-title case (now 1501) — backend touched suites + green. Frontend 393 vitest green; `tsc`/`eslint` clean; `npm run build` ok. + +### feat(branch): drop modal footer, wrap subtask titles, empty "+" when done (2026-06-03) + +- **Removed the Task Branch modal footer** — the "Changes save automatically / All changes saved" + autosave-status panel, the `View only` label and the `Done` button are gone. Editing still + autosaves (the `useAutosave` hooks are untouched); the modal closes via the header **✕**, the + backdrop, or `Escape`. Dropped the now-unused `AutosaveIndicator` wiring from the modal. +- **Long subtask titles now wrap** instead of truncating with an ellipsis. The subtask card is + flexible-height (`align-items: flex-start` + `overflow-wrap`), so a long step grows the card + downward to fit the branch width and the `layout` spring animates the height change. +- **The compose "+" menu is empty on a completed task** — description, subtask, and the + take-into-work / complete actions are all hidden once the task is done, and the menu doesn't open + (no empty popover). +- Tests/build: updated the two `EditTodoModal` footer assertions (no more "Changes save + automatically" / "View only" text); 393 vitest green; `tsc`/`eslint` clean; `npm run build` ok. + +### feat(subtasks): task-like cards, delete cascade, composer auto-close (2026-06-03) + +Follow-up polish on the inline subtask cards. + +- **Composer auto-closes** after a subtask is created — submitting returns the compose box to + plain-message mode instead of staying in subtask mode. +- **Delete affordance now matches a regular task card** — the inline trash icon is replaced by the + same **slide-from-right red panel** (clip-path reveal + spring trash icon) used on task cards. +- **Taller, more task-like card** — bigger glyph, roomier padding, and a `Subtask · HH:MM` meta line + under the title so a step reads like a compact task rather than a checklist row. +- **Deleting a subtask now also removes its branch announcements.** A subtask owns no branch, so + TodoApi emits the new `SubtaskDeletedIntegrationEvent(parentTaskId, subtaskId, actor, title)` + (instead of `TaskDeletedIntegrationEvent`, which would wipe a whole branch). Collaboration's new + `SubtaskDeletedEventConsumer` → `ICommentRepository.SoftDeleteSubtaskActivityAsync` soft-deletes + the parent-branch system comments ending with `added a subtask: {title}` / `completed a subtask: + {title}`. The client removes them optimistically and suppresses their ids so polling can't + re-add them before the async cascade lands. +- **Statistics (already in place, reconfirmed):** an incomplete subtask never counts in the active + task counter (`parentTodoId` filtered out), a completed subtask **does** count toward the weekly + completed stat (`includeSubtasks=true`), and subtasks never appear on `/tasks/completed` (the + list endpoints keep the default `ParentTodoId == null` filter). +- Tests: +1 Collaboration consumer case (subtask-delete targets the parent branch + title, never a + whole-branch wipe) and +1 Todo handler case (subtask delete enqueues `SubtaskDeletedIntegrationEvent` + for the parent) → 42 in the touched suites green. `tsc`/`eslint` clean; changed .NET libraries build clean. + +### refactor(subtasks): inline branch cards, no panel, no priority (2026-06-02) + +Reworked the subtask UX so a subtask is **a regular branch event**, authored just like the task +description — not a separate panel. + +- **Removed** the standalone Subtasks panel (`edit-todo-modal/subtasks-section.tsx` and its test) + that sat below the comments with its own header, progress bar and "Add" button. +- **Authoring mirrors the description flow.** A new "Subtask" entry in the compose "+" menu switches + the **same input field** into subtask mode; plain `Enter` adds the step and the field stays in + subtask mode for quick successive entry. No separate composer. +- **Inline rendering.** Each subtask now renders as a **simplified task card on the activity rail** + (`branch-feed.tsx` → `SubtaskCard`), interleaved chronologically and anchored **directly after its + "added a subtask" system event** (matched by the `: <title>` suffix) — never pinned to the top. The + completion toggle doubles as the rail marker, sitting exactly on the timeline line. +- **No priority.** The priority picker/dot is gone from subtask create and edit; only the title is + authored/edited. The entity column still defaults server-side but is never surfaced. +- Live polling/merge now refreshes subtasks alongside comments (id-keyed, optimism-preserving), so + another participant's add/complete/edit appears without re-opening the modal. Per-row complete + (everyone) / inline title edit + take-into-work + delete (owner) retained, with satisfying + spring-based toggle, enter and exit animations. +- Tests/build: removed the obsolete `SubtasksSection` component test; `subtasks-api` tests green; + `tsc` + `eslint` clean; `npm run build` succeeds. + +### feat(subtasks): branch system messages on create/complete + non-bold rendering (2026-06-02) + +- Creating or completing a subtask now posts a **system message to the parent task's branch** + ("X added a subtask: …" / "X completed a subtask: …"). Reuses `TaskActivityIntegrationEvent` + with new `SubtaskCreated`/`SubtaskCompleted` types and an optional `Detail` (the subtask title); + the Collaboration `TaskActivityEventConsumer` formats the sentence on the parent's timeline. + `CreateSubtaskCommandHandler` and both completion paths in `UpdateTodoCommandHandler` (owner and + non-owner global completion) enqueue the event via the outbox. +- Subtasks (title only, no description) now render **non-bold** in the branch, so a step reads as a + plain entry, lighter than the bold Author's Note. +- Tests: +2 backend consumer cases (subtask sentence incl. title, posted to the parent id) and + outbox-emission assertions on create/complete → backend suite 155 green; frontend 400 green. + +### feat — subtasks: tree-structured child tasks inside a task's branch (2026-06-02) + +Tasks can now be broken into **subtasks** — child `TodoItem`s (self-referencing `ParentTodoId`) +that live **only** in the parent task's branch and never appear on any list/page. + +- **Todo backend.** `TodoItem.ParentTodoId` + `CreateSubtask` (inherits the parent's category, + public flag and shared audience; own priority; no due/expected date; no nesting) and + `SyncInheritedFromParent`. EF self-FK (NoAction) + index + migration `AddSubtaskParentTodoId`. + `CreateSubtaskCommand` and `GetSubtasksQuery` (owner-create; owner/friend list with the + `GetTodoById` visibility predicate). Endpoints `POST`/`GET /todos/api/v1/todos/{id}/subtasks`. + Completion is **global** — anyone who can see the parent may complete/reopen a subtask and it + applies for everyone (entity status, not per-viewer); editing a subtask's title/priority is + **owner-only**. `UpdateTodo` rejects editing a subtask's inherited fields and **propagates** a + parent's category/visibility/sharing to its children; + `DeleteTodo` soft-deletes the whole subtree. List queries exclude subtasks; `GetUserTodos` + gains `includeSubtasks` so **completed subtasks still count toward the weekly dashboard stat**. +- **Frontend.** New "Subtask" entry in the branch "+" menu; `edit-todo-modal/subtasks-section.tsx` + renders an animated checklist (progress bar, smooth add/remove). Anyone with access can complete; + the owner can inline-edit title + priority (double-click or pencil), take into work, and delete. `fetchSubtasks`/`createSubtask`/`updateSubtask`/ + `deleteSubtask` API helpers; `Todo.parentTodoId`. Dashboard counts completed subtasks weekly while + excluding them from the active counter and the grid. +- **Tests/docs.** 10 backend handler/security tests (inheritance, foreign-parent + nesting rejection, + owner-only edit guard, non-owner global completion, parent→child propagation, cascade delete, + access control) — Todo suite 121 green; 11 frontend tests (API helpers + `SubtasksSection`) — + suite 400 green. IDOR coverage doc + features doc updated. Backend builds clean (.NET 10); + `tsc`/`eslint` clean; frontend branch coverage ≥85%. + +### feat(frontend) — quick-save (autosave) for the task-branch and category edit modals (2026-06-02) + +Removed the manual **Save/Cancel** buttons from the editing modals: every committed change now +persists automatically, as soon as it is applied. + +- **New `useAutosave` hook** (`frontend/src/hooks/use-autosave.ts`) — a debounced, single-flight + autosave engine: it coalesces bursts (typing, color-picker drags) into one write, never persists a + value equal to the last-saved baseline, runs at most one request at a time (re-firing if the value + changed mid-flight so the final state always lands), exposes `idle/saving/saved/error` status, and + `flush()`es any pending change on explicit close and on unmount so nothing is lost. A `validate` + guard blocks invalid saves (e.g. an empty name/title) and `reset()` re-anchors the baseline when the + edited entity changes. +- **New `AutosaveIndicator`** (`frontend/src/components/ui/autosave-indicator.tsx`) — a quiet + `role="status"` `aria-live="polite"` confirmation (`Saving… / All changes saved / Couldn’t save`) + that replaces the Save button as the only signal a change reached the server. +- **Task Branch edit modal** (`edit-todo-modal/modal.tsx`) — title, priority, due date, category, and + visibility/sharing autosave. Owners persist the full task payload; a shared viewer autosaves only + their private category preference. The description ("Author's Note") keeps its own editor and is + excluded from the autosave equality check to avoid a duplicate write. The footer now shows the + indicator (or `View only`) plus a `Done` close button. `tasks/page.tsx` `handleUpdate` / + `handleSaveViewerPreference` no longer close the modal or toast per save, update the list optimistically, + and re-throw on failure so the indicator can show the error state and retry. +- **Category edit modal** (`categories/page.tsx`) — name, description, color, and icon autosave with an + optimistic grid update; an empty name shows an inline hint and is never persisted. **Creating** a + category intentionally keeps a single explicit `Create category` button (nothing exists to autosave + yet), with no Cancel button. +- **Tests** — added `frontend/src/test/hooks/use-autosave.test.tsx` (12 cases: debounce, baseline, + status, revert, validation, enable/disable, error, flush, reset, single-flight, unmount flush) and + `frontend/src/test/components/autosave-indicator.test.tsx`; rewrote the four `EditTodoModal` tests for + autosave. Full suite: **389 green**, `tsc --noEmit` and `eslint .` clean. + ### fix(ci) — green markdownlint + restore branch-coverage threshold (2026-06-02) - Converted every remaining `*`-style list bullet in `CHANGELOG.md` to `-` (272 MD004 violations). diff --git a/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs index f3e921a8..4832a956 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Api/Program.cs @@ -159,6 +159,9 @@ await DatabaseStartup.EnsureReadyAsync( await eventBus.SubscribeAsync<TaskDeletedIntegrationEvent, TaskDeletedEventConsumer>(app.Lifetime.ApplicationStopping); logger.LogInformation("✅ Subscribed to TaskDeletedIntegrationEvent"); + await eventBus.SubscribeAsync<SubtaskDeletedIntegrationEvent, SubtaskDeletedEventConsumer>(app.Lifetime.ApplicationStopping); + logger.LogInformation("✅ Subscribed to SubtaskDeletedIntegrationEvent"); + await eventBus.SubscribeAsync<UserDeletedIntegrationEvent, UserDeletedEventConsumer>(app.Lifetime.ApplicationStopping); logger.LogInformation("✅ Subscribed to UserDeletedIntegrationEvent"); } diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs b/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs index f9d8d3f3..08326989 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Application/DependencyInjection.cs @@ -32,6 +32,7 @@ public static IServiceCollection AddCollaborationApplication(this IServiceCollec services.AddScoped<TaskCreatedEventConsumer>(); services.AddScoped<TaskActivityEventConsumer>(); services.AddScoped<TaskDeletedEventConsumer>(); + services.AddScoped<SubtaskDeletedEventConsumer>(); services.AddScoped<UserDeletedEventConsumer>(); return services; diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/SubtaskDeletedEventConsumer.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/SubtaskDeletedEventConsumer.cs new file mode 100644 index 00000000..b1a2c12a --- /dev/null +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/SubtaskDeletedEventConsumer.cs @@ -0,0 +1,40 @@ +using Planora.BuildingBlocks.Application.Messaging; +using Planora.BuildingBlocks.Application.Messaging.Events; +using Planora.Collaboration.Domain.Repositories; + +namespace Planora.Collaboration.Application.Features.IntegrationEvents +{ + /// <summary> + /// Removes a deleted subtask's announcement comments from the PARENT task's branch. A subtask + /// has no branch of its own, so deleting it must not wipe the parent timeline — only the + /// "added a subtask: …" / "completed a subtask: …" system comments it produced. Naturally + /// idempotent: a redelivered event finds no remaining matching comments. + /// </summary> + public sealed class SubtaskDeletedEventConsumer : IIntegrationEventHandler<SubtaskDeletedIntegrationEvent> + { + private readonly ICommentRepository _commentRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger<SubtaskDeletedEventConsumer> _logger; + + public SubtaskDeletedEventConsumer( + ICommentRepository commentRepository, + IUnitOfWork unitOfWork, + ILogger<SubtaskDeletedEventConsumer> logger) + { + _commentRepository = commentRepository; + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task HandleAsync(SubtaskDeletedIntegrationEvent @event, CancellationToken cancellationToken) + { + await _commentRepository.SoftDeleteSubtaskActivityAsync( + @event.ParentTaskId, @event.Title, @event.ActorId, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Soft-deleted subtask {SubtaskId} announcement comments in parent branch {ParentTaskId} (actor {ActorId})", + @event.SubtaskId, @event.ParentTaskId, @event.ActorId); + } + } +} diff --git a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs index 8986a17f..a8009a5e 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Application/Features/IntegrationEvents/TaskActivityEventConsumer.cs @@ -29,12 +29,19 @@ public TaskActivityEventConsumer( public async Task HandleAsync(TaskActivityIntegrationEvent @event, CancellationToken cancellationToken) { var actorName = string.IsNullOrWhiteSpace(@event.ActorName) ? "Someone" : @event.ActorName; + var detail = @event.Detail?.Trim(); var text = @event.ActivityType switch { TaskActivityType.Completed => $"{actorName} completed the task", TaskActivityType.StartedWorking => $"{actorName} started working on the task", TaskActivityType.Left => $"{actorName} left the task", + TaskActivityType.SubtaskCreated => string.IsNullOrWhiteSpace(detail) + ? $"{actorName} added a subtask" + : $"{actorName} added a subtask: {detail}", + TaskActivityType.SubtaskCompleted => string.IsNullOrWhiteSpace(detail) + ? $"{actorName} completed a subtask" + : $"{actorName} completed a subtask: {detail}", _ => null }; diff --git a/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs b/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs index c54552c4..a004a624 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Domain/Repositories/ICommentRepository.cs @@ -8,5 +8,13 @@ public interface ICommentRepository : IRepository<Comment> Task<(IReadOnlyList<Comment> Items, int TotalCount)> GetPagedByTaskIdAsync( Guid taskId, int pageNumber, int pageSize, CancellationToken ct = default); Task SoftDeleteByTaskIdAsync(Guid taskId, Guid deletedBy, CancellationToken ct = default); + + /// <summary> + /// Soft-deletes the subtask announcement system comments ("… added a subtask: {title}" / + /// "… completed a subtask: {title}") within a parent task's branch, used when that subtask + /// is deleted. Matches the deterministic sentence suffix produced by the activity consumer. + /// </summary> + Task SoftDeleteSubtaskActivityAsync( + Guid parentTaskId, string subtaskTitle, Guid deletedBy, CancellationToken ct = default); } } diff --git a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs index 386e5ca7..a75db65f 100644 --- a/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs +++ b/Services/CollaborationApi/Planora.Collaboration.Infrastructure/Persistence/Repositories/CommentRepository.cs @@ -51,5 +51,28 @@ public async Task SoftDeleteByTaskIdAsync(Guid taskId, Guid deletedBy, Cancellat foreach (var comment in comments) comment.MarkAsDeleted(deletedBy); } + + public async Task SoftDeleteSubtaskActivityAsync( + Guid parentTaskId, string subtaskTitle, Guid deletedBy, CancellationToken ct = default) + { + var title = (subtaskTitle ?? string.Empty).Trim(); + if (title.Length == 0) + return; + + // The activity consumer writes deterministic sentences ending with the subtask title, + // e.g. "Ann added a subtask: Buy milk". Match those exact suffixes within the parent's + // branch so we never touch a regular comment or a different subtask's announcement. + var addedSuffix = $"added a subtask: {title}"; + var completedSuffix = $"completed a subtask: {title}"; + + // Load-then-update keeps parity with SoftDeleteByTaskIdAsync (InMemory-friendly). + var comments = await DbSet + .Where(c => c.TaskId == parentTaskId && c.IsSystemComment && !c.IsDeleted && + (c.Content.EndsWith(addedSuffix) || c.Content.EndsWith(completedSuffix))) + .ToListAsync(ct); + + foreach (var comment in comments) + comment.MarkAsDeleted(deletedBy); + } } } diff --git a/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs b/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs index f2ae59ae..d157f988 100644 --- a/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs +++ b/Services/TodoApi/Planora.Todo.Api/Controllers/TodosController.cs @@ -12,6 +12,8 @@ using Planora.Todo.Application.Features.Todos.Queries.GetTodoById; using Planora.Todo.Application.Features.Todos.Commands.JoinTodo; using Planora.Todo.Application.Features.Todos.Commands.LeaveTodo; +using Planora.Todo.Application.Features.Todos.Commands.CreateSubtask; +using Planora.Todo.Application.Features.Todos.Queries.GetSubtasks; namespace Planora.Todo.Api.Controllers { @@ -39,9 +41,12 @@ public async Task<ActionResult<PagedResult<TodoItemDto>>> GetTodos( [FromQuery] string? status = null, [FromQuery] Guid? categoryId = null, [FromQuery] bool? isCompleted = null, + // Subtasks are excluded from lists by default; the dashboard stats fetch opts in so + // completed subtasks still count toward weekly statistics. + [FromQuery] bool includeSubtasks = false, CancellationToken cancellationToken = default) { - var query = new GetUserTodosQuery(null, pageNumber, pageSize, status, categoryId, isCompleted); + var query = new GetUserTodosQuery(null, pageNumber, pageSize, status, categoryId, isCompleted, includeSubtasks); var result = await _mediator.Send(query, cancellationToken); return Ok(result); @@ -77,6 +82,50 @@ public async Task<ActionResult<TodoItemDto>> GetTodoById( return Ok(result.Value); } + /// <summary> + /// Lists the subtasks (children) of a task. Visible to the owner, or to a friend for a + /// shared/public parent. Subtasks live only in the task's branch. + /// </summary> + [HttpGet("{id}/subtasks")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<IReadOnlyList<TodoItemDto>>> GetSubtasks( + [FromRoute] Guid id, + CancellationToken cancellationToken = default) + { + var result = await _mediator.Send(new GetSubtasksQuery(id), cancellationToken); + + if (result.IsFailure) + return NotFound(result.Error); + + return Ok(result.Value); + } + + /// <summary> + /// Creates a subtask under a task (owner-only). The subtask inherits the parent's + /// category, public flag and shared audience; it has its own title/priority and no dates. + /// </summary> + [HttpPost("{id}/subtasks")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<TodoItemDto>> CreateSubtask( + [FromRoute] Guid id, + [FromBody] CreateSubtaskCommand command, + CancellationToken cancellationToken = default) + { + var createCommand = command with { ParentTodoId = id }; // parent comes from the route, never the body + var result = await _mediator.Send(createCommand, cancellationToken); + + if (result.IsFailure) + return BadRequest(result.Error); + + return CreatedAtAction( + nameof(GetTodoById), + new { id = result.Value!.Id }, + result.Value); + } + [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/Services/TodoApi/Planora.Todo.Api/Program.cs b/Services/TodoApi/Planora.Todo.Api/Program.cs index ef861b51..cd869c08 100644 --- a/Services/TodoApi/Planora.Todo.Api/Program.cs +++ b/Services/TodoApi/Planora.Todo.Api/Program.cs @@ -159,6 +159,35 @@ await DatabaseStartup.EnsureReadyAsync( } } + // Subtasks allow up to 1500-character titles (a subtask's whole content is its + // title). The shared TodoItems.Title column historically was varchar(200); widen + // it on existing migration-built databases so long subtask titles persist. This + // is idempotent and metadata-only in PostgreSQL (a varchar length *increase* + // never rewrites the table), and guarded so it only runs while still too narrow. + // Fresh installs already get 1500 from the EF model (TodoItemConfiguration). + try + { + // The TodoItems table lives in the "todo" schema — qualify it explicitly + // (an unqualified name resolves against the search_path/public and fails). + await db.Database.ExecuteSqlRawAsync(@" +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'todo' AND table_name = 'TodoItems' AND column_name = 'Title' + AND character_maximum_length IS NOT NULL + AND character_maximum_length < 1500 + ) THEN + ALTER TABLE todo.""TodoItems"" ALTER COLUMN ""Title"" TYPE varchar(1500); + END IF; +END $$;", app.Lifetime.ApplicationStopping); + logger.LogInformation("✅ Ensured TodoItems.Title accommodates 1500-character subtask titles"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not reconcile TodoItems.Title column width (non-fatal)"); + } + // Subscribe to Integration Events logger.LogInformation("🔄 Subscribing to integration events..."); var eventBus = provider.GetRequiredService<IEventBus>(); diff --git a/Services/TodoApi/Planora.Todo.Application/DTOs/TodoItemDto.cs b/Services/TodoApi/Planora.Todo.Application/DTOs/TodoItemDto.cs index faad10ef..a27f20a2 100644 --- a/Services/TodoApi/Planora.Todo.Application/DTOs/TodoItemDto.cs +++ b/Services/TodoApi/Planora.Todo.Application/DTOs/TodoItemDto.cs @@ -40,6 +40,9 @@ public sealed record TodoItemDto public bool IsWorking { get; init; } public IReadOnlyList<Guid> WorkerUserIds { get; init; } = Array.Empty<Guid>(); public bool? IsCompletedByViewer { get; init; } + + /// <summary>When set, this item is a subtask (child) of the given parent task.</summary> + public Guid? ParentTodoId { get; init; } } public class TodoItemMappingProfile : Profile diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommand.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommand.cs new file mode 100644 index 00000000..d606f527 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommand.cs @@ -0,0 +1,17 @@ +using Planora.BuildingBlocks.Domain; +using Planora.Todo.Application.DTOs; +using Planora.Todo.Domain.Enums; + +namespace Planora.Todo.Application.Features.Todos.Commands.CreateSubtask +{ + /// <summary> + /// Creates a subtask under a parent task. The subtask inherits the parent's category, + /// public flag and shared audience; it carries its own title, optional description and + /// priority, and never a due/expected date. Owner-only. + /// </summary> + public sealed record CreateSubtaskCommand( + Guid ParentTodoId, + string Title, + string? Description = null, + TodoPriority Priority = TodoPriority.Medium) : ICommand<Result<TodoItemDto>>; +} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommandHandler.cs new file mode 100644 index 00000000..5b905e81 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommandHandler.cs @@ -0,0 +1,103 @@ +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Todo.Application.DTOs; +using Planora.Todo.Application.Interfaces; +using Planora.Todo.Application.Services; +using Planora.Todo.Domain.Entities; +using Planora.Todo.Domain.Repositories; + +namespace Planora.Todo.Application.Features.Todos.Commands.CreateSubtask +{ + public sealed class CreateSubtaskCommandHandler : IRequestHandler<CreateSubtaskCommand, Result<TodoItemDto>> + { + private readonly ITodoRepository _repository; + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly ILogger<CreateSubtaskCommandHandler> _logger; + private readonly ICurrentUserContext _currentUserContext; + private readonly ICategoryGrpcClient _categoryGrpcClient; + + public CreateSubtaskCommandHandler( + ITodoRepository repository, + IUnitOfWork unitOfWork, + IMapper mapper, + ILogger<CreateSubtaskCommandHandler> logger, + ICurrentUserContext currentUserContext, + ICategoryGrpcClient categoryGrpcClient) + { + _repository = repository; + _unitOfWork = unitOfWork; + _mapper = mapper; + _logger = logger; + _currentUserContext = currentUserContext; + _categoryGrpcClient = categoryGrpcClient; + } + + public async Task<Result<TodoItemDto>> Handle( + CreateSubtaskCommand request, + CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + if (userId == Guid.Empty) + throw new UnauthorizedAccessException("User context is not available"); + + // Load the parent tracked so the new child is attached in the same unit of work and + // the parent's current category/visibility/shares can be inherited. + var parent = await _repository.GetByIdWithIncludesTrackedAsync(request.ParentTodoId, cancellationToken) + ?? throw new EntityNotFoundException("TodoItem", request.ParentTodoId); + + // Only the owner can add subtasks (IDOR guard); never to another subtask (no nesting). + if (parent.UserId != userId) + throw new ForbiddenException("You can only add subtasks to your own tasks"); + if (parent.IsSubtask) + throw new ForbiddenException("A subtask cannot have its own subtasks"); + + var subtask = TodoItem.CreateSubtask( + parent, + userId, + request.Title, + request.Description, + request.Priority); + + await _repository.AddAsync(subtask, cancellationToken); + + // Subtask creation is deliberately NOT announced in the parent's branch — there is no + // "created a subtask" system notification. (Completion still emits SubtaskCompleted.) + await _unitOfWork.SaveChangesAsync(cancellationToken); + + _logger.LogInformation( + "Subtask {SubtaskId} created under task {ParentId} by user {UserId}", + subtask.Id, parent.Id, userId); + + var dto = _mapper.Map<TodoItemDto>(subtask); + + // Enrich with the (inherited) category so the subtask card shows the same label as the parent. + if (subtask.CategoryId.HasValue) + { + try + { + var categoryInfo = await _categoryGrpcClient.GetCategoryInfoAsync( + subtask.CategoryId.Value, userId, cancellationToken); + if (categoryInfo is not null) + { + dto = dto with + { + CategoryName = categoryInfo.Name, + CategoryColor = categoryInfo.Color, + CategoryIcon = categoryInfo.Icon, + }; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not enrich subtask {SubtaskId} with category {CategoryId}", + subtask.Id, subtask.CategoryId.Value); + } + } + + return Result<TodoItemDto>.Success(dto); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommandValidator.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommandValidator.cs new file mode 100644 index 00000000..fcb21670 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/CreateSubtask/CreateSubtaskCommandValidator.cs @@ -0,0 +1,22 @@ +namespace Planora.Todo.Application.Features.Todos.Commands.CreateSubtask +{ + public sealed class CreateSubtaskCommandValidator : AbstractValidator<CreateSubtaskCommand> + { + public CreateSubtaskCommandValidator() + { + RuleFor(x => x.ParentTodoId) + .NotEmpty().WithMessage("Parent task is required"); + + // A subtask's whole content lives in its title (it has no separate body), so it gets a + // generous 1500-character allowance — far larger than a regular task's 200-char title. + RuleFor(x => x.Title) + .NotEmpty().WithMessage("Title is required") + .MaximumLength(1500).WithMessage("Title cannot exceed 1500 characters"); + + // Aligned with the TodoItem.Description column (varchar(2000)). + RuleFor(x => x.Description) + .MaximumLength(2000).WithMessage("Description cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Description)); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs index d989d66f..d5f1dfb7 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs @@ -11,14 +11,14 @@ namespace Planora.Todo.Application.Features.Todos.Commands.DeleteTodo { public sealed class DeleteTodoCommandHandler : IRequestHandler<DeleteTodoCommand, Result> { - private readonly IRepository<TodoItem> _repository; + private readonly ITodoRepository _repository; private readonly IOutboxRepository _outboxRepository; private readonly IUnitOfWork _unitOfWork; private readonly ILogger<DeleteTodoCommandHandler> _logger; private readonly ICurrentUserContext _currentUserContext; public DeleteTodoCommandHandler( - IRepository<TodoItem> repository, + ITodoRepository repository, IOutboxRepository outboxRepository, IUnitOfWork unitOfWork, ILogger<DeleteTodoCommandHandler> logger, @@ -44,11 +44,35 @@ public async Task<Result> Handle(DeleteTodoCommand request, CancellationToken ca todoItem.MarkAsDeleted(userId); _repository.Update(todoItem); + // Deleting a task removes its whole subtree: soft-delete every subtask in the same + // unit of work. (A subtask itself has no children, so this is a no-op for subtasks.) + if (!todoItem.IsSubtask) + { + var subtasks = await _repository.GetSubtasksTrackedAsync(todoItem.Id, cancellationToken); + foreach (var subtask in subtasks) + { + subtask.MarkAsDeleted(userId); + _repository.Update(subtask); + } + } + // The task's comment timeline ("ветка") lives in the Collaboration service. Publish a // deletion fact via the outbox; Collaboration cascade-soft-deletes the comments. INV-COMM-3. - await _outboxRepository.EnqueueIntegrationEventAsync( - new TaskDeletedIntegrationEvent(todoItem.Id, userId), - cancellationToken); + if (todoItem.IsSubtask) + { + // A subtask has no branch of its own — its only footprint is the announcement + // comments it left in the PARENT's branch. Remove exactly those, not a whole branch. + await _outboxRepository.EnqueueIntegrationEventAsync( + new SubtaskDeletedIntegrationEvent( + todoItem.ParentTodoId!.Value, todoItem.Id, userId, todoItem.Title), + cancellationToken); + } + else + { + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskDeletedIntegrationEvent(todoItem.Id, userId), + cancellationToken); + } await _unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs index d0c99de3..f10fc213 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs @@ -49,7 +49,12 @@ public async Task<Result<TodoItemDto>> Handle(JoinTodoCommand request, Cancellat var todoItem = await _repository.GetByIdWithIncludesTrackedAsync(request.TodoId, cancellationToken) ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - if (todoItem.UserId == userId) + // Subtask "in work" is per-user: anyone with access (the owner included) opts in as a + // worker and is counted, with NO branch notification. A normal task keeps the classic + // model (the owner is implicitly working; only collaborators hold worker rows). + var isSubtask = todoItem.IsSubtask; + + if (todoItem.UserId == userId && !isSubtask) { var ownerDto = _mapper.Map<TodoItemDto>(todoItem) with { @@ -61,16 +66,20 @@ public async Task<Result<TodoItemDto>> Handle(JoinTodoCommand request, Cancellat return Result<TodoItemDto>.Success(ownerDto); } - var canAccess = todoItem.IsPublic || todoItem.SharedWith.Any(s => s.SharedWithUserId == userId); - if (!canAccess) - throw new ForbiddenException("You do not have access to this task"); - - // For shared (non-public) tasks, require friendship; public tasks are open to anyone - if (!todoItem.IsPublic) + // The owner always has access to their own subtask; everyone else needs share/public + friendship. + if (todoItem.UserId != userId) { - var areFriends = await _friendshipService.AreFriendsAsync(userId, todoItem.UserId, cancellationToken); - if (!areFriends) - throw new ForbiddenException("You must be friends with the task owner to join"); + var canAccess = todoItem.IsPublic || todoItem.SharedWith.Any(s => s.SharedWithUserId == userId); + if (!canAccess) + throw new ForbiddenException("You do not have access to this task"); + + // For shared (non-public) tasks, require friendship; public tasks are open to anyone + if (!todoItem.IsPublic) + { + var areFriends = await _friendshipService.AreFriendsAsync(userId, todoItem.UserId, cancellationToken); + if (!areFriends) + throw new ForbiddenException("You must be friends with the task owner to join"); + } } // Idempotent: already a worker → return current state as success @@ -88,10 +97,15 @@ public async Task<Result<TodoItemDto>> Handle(JoinTodoCommand request, Cancellat todoItem.AddWorker(userId); - var userName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); - await _outboxRepository.EnqueueIntegrationEventAsync( - new TaskActivityIntegrationEvent(todoItem.Id, userId, userName, TaskActivityType.StartedWorking), - cancellationToken); + // Subtasks have no branch of their own and never post a "started working" notification — + // their in-work state is shown only as an anonymous worker count. Normal tasks announce it. + if (!isSubtask) + { + var userName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskActivityIntegrationEvent(todoItem.Id, userId, userName, TaskActivityType.StartedWorking), + cancellationToken); + } await _unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs index 99196b89..790d599d 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs @@ -41,15 +41,22 @@ public async Task<Result> Handle(LeaveTodoCommand request, CancellationToken can var todoItem = await _repository.GetByIdWithIncludesTrackedAsync(request.TodoId, cancellationToken) ?? throw new EntityNotFoundException("TodoItem", request.TodoId); - if (todoItem.UserId == userId) + // Subtask in-work is per-user (the owner can opt in/out too), so the owner may leave a + // subtask. On a normal task the owner is always its worker and cannot leave. + var isSubtask = todoItem.IsSubtask; + if (todoItem.UserId == userId && !isSubtask) throw new BusinessRuleViolationException("Owner cannot leave their own task"); todoItem.RemoveWorker(userId); - var userName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); - await _outboxRepository.EnqueueIntegrationEventAsync( - new TaskActivityIntegrationEvent(todoItem.Id, userId, userName, TaskActivityType.Left), - cancellationToken); + // Subtasks post no "left the task" notification — their in-work state is an anonymous count. + if (!isSubtask) + { + var userName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskActivityIntegrationEvent(todoItem.Id, userId, userName, TaskActivityType.Left), + cancellationToken); + } await _unitOfWork.SaveChangesAsync(cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs index 9cfd46be..64835be0 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs @@ -86,6 +86,54 @@ public async Task<Result<TodoItemDto>> Handle( throw new ForbiddenException("You can only mark friend-visible tasks as complete, not edit them"); } + // Subtasks complete GLOBALLY: anyone with access marks the subtask done (or reopens + // it) for everyone — its status lives on the entity, not per-viewer. (Editing a + // subtask's title/priority remains owner-only, enforced by the guard above.) + if (todoItem.IsSubtask) + { + if (!string.IsNullOrEmpty(request.Status)) + { + var subtaskStatus = TodoStatusExtensions.FromString(request.Status); + if (subtaskStatus.HasValue) + { + var subtaskJustCompleted = false; + if (subtaskStatus == TodoStatus.Done && !todoItem.IsCompleted) + { + todoItem.MarkAsDone(userId); + subtaskJustCompleted = true; + } + else if (subtaskStatus == TodoStatus.InProgress && todoItem.Status != TodoStatus.InProgress) + todoItem.MarkAsInProgress(userId); + else if (subtaskStatus == TodoStatus.Todo && todoItem.Status != TodoStatus.Todo) + todoItem.MarkAsTodo(userId); + + _repository.Update(todoItem); + + // Announce completion in the PARENT task's branch ("X completed a subtask: …"). + if (subtaskJustCompleted && todoItem.ParentTodoId.HasValue) + { + var completerName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskActivityIntegrationEvent( + todoItem.ParentTodoId.Value, userId, completerName, + TaskActivityType.SubtaskCompleted, todoItem.Title), + cancellationToken); + } + + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + } + + return Result<TodoItemDto>.Success(_mapper.Map<TodoItemDto>(todoItem) with + { + WorkerCount = todoItem.Workers.Count, + WorkerUserIds = todoItem.Workers.Select(w => w.UserId).ToList(), + RequiredWorkers = todoItem.RequiredWorkers, + IsWorking = todoItem.Workers.Any(w => w.UserId == userId), + CategoryId = null, CategoryName = null, CategoryColor = null, CategoryIcon = null, + }); + } + // Record completion per-viewer instead of changing the shared task for everyone if (!string.IsNullOrEmpty(request.Status)) { @@ -142,6 +190,17 @@ await _outboxRepository.EnqueueIntegrationEventAsync( }); } + // A subtask inherits category, visibility, sharing and dates from its parent — they + // are never editable on the child directly. The owner may still change a subtask's + // title, description, priority and status. (Defense in depth: the UI never sends these.) + if (todoItem.IsSubtask && + (request.CategoryId.HasValue || request.IsPublic.HasValue || request.SharedWithUserIds != null || + request.DueDate != null || request.ExpectedDate != null || + request.RequiredWorkers.HasValue || request.ClearRequiredWorkers)) + { + throw new ForbiddenException("A subtask inherits category, visibility and dates from its parent"); + } + if (!string.IsNullOrEmpty(request.Title)) todoItem.UpdateTitle(request.Title, userId); @@ -237,20 +296,44 @@ await _outboxRepository.EnqueueIntegrationEventAsync( _repository.Update(todoItem); + // Keep subtasks in sync: when the parent's category, public flag or shared audience + // changes, re-anchor every child so "a subtask is always as visible as its parent" + // stays true. Done in the same unit of work as the parent update. + if (!todoItem.IsSubtask && + (request.CategoryId.HasValue || request.IsPublic.HasValue || request.SharedWithUserIds != null)) + { + var children = await _repository.GetSubtasksTrackedAsync(todoItem.Id, cancellationToken); + foreach (var child in children) + { + child.SyncInheritedFromParent(todoItem, userId); + _repository.Update(child); + } + } + var ownerName = _currentUserContext.Name ?? _currentUserContext.Email ?? userId.ToString(); - if (ownerJustCompleted) + // Subtasks have no branch of their own, so they emit no timeline activity events. + if (ownerJustCompleted && !todoItem.IsSubtask) { await _outboxRepository.EnqueueIntegrationEventAsync( new TaskActivityIntegrationEvent(todoItem.Id, userId, ownerName, TaskActivityType.Completed), cancellationToken); } - else if (ownerStartedWorking) + else if (ownerStartedWorking && !todoItem.IsSubtask) { await _outboxRepository.EnqueueIntegrationEventAsync( new TaskActivityIntegrationEvent(todoItem.Id, userId, ownerName, TaskActivityType.StartedWorking), cancellationToken); } - else if (ownerStoppedWorking) + else if (ownerJustCompleted && todoItem.IsSubtask && todoItem.ParentTodoId.HasValue) + { + // A subtask has no branch of its own — its completion is announced in the PARENT's branch. + await _outboxRepository.EnqueueIntegrationEventAsync( + new TaskActivityIntegrationEvent( + todoItem.ParentTodoId.Value, userId, ownerName, + TaskActivityType.SubtaskCompleted, todoItem.Title), + cancellationToken); + } + else if (ownerStoppedWorking && !todoItem.IsSubtask) { await _outboxRepository.EnqueueIntegrationEventAsync( new TaskActivityIntegrationEvent(todoItem.Id, userId, ownerName, TaskActivityType.Left), diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandValidator.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandValidator.cs index 4f2a2674..a7fa63fa 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandValidator.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Commands/UpdateTodo/UpdateTodoCommandValidator.cs @@ -9,8 +9,12 @@ public UpdateTodoCommandValidator() RuleFor(x => x.TodoId) .NotEmpty().WithMessage("Todo ID is required"); + // This command edits both regular tasks and subtasks (a subtask rename also goes + // through here), so the cap matches the larger 1500-char subtask-title allowance and + // the widened TodoItems.Title column. Regular-task titles are kept to 200 chars by the + // create validator and the UI input limits. RuleFor(x => x.Title) - .MaximumLength(200).WithMessage("Title cannot exceed 200 characters") + .MaximumLength(1500).WithMessage("Title cannot exceed 1500 characters") .When(x => !string.IsNullOrEmpty(x.Title)); // See CreateTodoCommandValidator for the rationale — must match diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetPublicTodos/GetPublicTodosQueryHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetPublicTodos/GetPublicTodosQueryHandler.cs index de15c733..40db7a25 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetPublicTodos/GetPublicTodosQueryHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetPublicTodos/GetPublicTodosQueryHandler.cs @@ -68,6 +68,7 @@ public async Task<Result<PagedResult<TodoItemDto>>> Handle( var (friendItems, friendTotalCount) = await _repository.FindPageWithIncludesAsync( t => t.UserId == request.FriendId && + t.ParentTodoId == null && (t.IsPublic || t.SharedWith.Any(s => s.SharedWithUserId == userId)) && !t.IsDeleted && !hiddenTodoIds.Contains(t.Id), @@ -126,6 +127,7 @@ public async Task<Result<PagedResult<TodoItemDto>>> Handle( var predicate = (System.Linq.Expressions.Expression<Func<TodoItem, bool>>)(t => friendIdsList.Contains(t.UserId) && + t.ParentTodoId == null && (t.IsPublic || t.SharedWith.Any(s => s.SharedWithUserId == userId)) && !t.IsDeleted && !hiddenTodoIds.Contains(t.Id)); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetSubtasks/GetSubtasksQuery.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetSubtasks/GetSubtasksQuery.cs new file mode 100644 index 00000000..22761418 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetSubtasks/GetSubtasksQuery.cs @@ -0,0 +1,11 @@ +using Planora.BuildingBlocks.Domain; +using Planora.Todo.Application.DTOs; + +namespace Planora.Todo.Application.Features.Todos.Queries.GetSubtasks +{ + /// <summary> + /// Returns the subtasks of a task, oldest first. The caller must be able to see the parent + /// (owner, or a friend for a shared/public parent). Used only by the task's branch view. + /// </summary> + public sealed record GetSubtasksQuery(Guid ParentTodoId) : IQuery<Result<IReadOnlyList<TodoItemDto>>>; +} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetSubtasks/GetSubtasksQueryHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetSubtasks/GetSubtasksQueryHandler.cs new file mode 100644 index 00000000..177922f3 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetSubtasks/GetSubtasksQueryHandler.cs @@ -0,0 +1,106 @@ +using Planora.BuildingBlocks.Application.Context; +using Planora.BuildingBlocks.Domain; +using Planora.BuildingBlocks.Domain.Exceptions; +using Planora.Todo.Application.DTOs; +using Planora.Todo.Application.Interfaces; +using Planora.Todo.Application.Services; +using Planora.Todo.Domain.Repositories; + +namespace Planora.Todo.Application.Features.Todos.Queries.GetSubtasks +{ + public sealed class GetSubtasksQueryHandler : IQueryHandler<GetSubtasksQuery, Result<IReadOnlyList<TodoItemDto>>> + { + private readonly ITodoRepository _repository; + private readonly ICurrentUserContext _currentUserContext; + private readonly IMapper _mapper; + private readonly ILogger<GetSubtasksQueryHandler> _logger; + private readonly IFriendshipService _friendshipService; + private readonly ICategoryGrpcClient _categoryGrpcClient; + + public GetSubtasksQueryHandler( + ITodoRepository repository, + ICurrentUserContext currentUserContext, + IMapper mapper, + ILogger<GetSubtasksQueryHandler> logger, + IFriendshipService friendshipService, + ICategoryGrpcClient categoryGrpcClient) + { + _repository = repository; + _currentUserContext = currentUserContext; + _mapper = mapper; + _logger = logger; + _friendshipService = friendshipService; + _categoryGrpcClient = categoryGrpcClient; + } + + public async Task<Result<IReadOnlyList<TodoItemDto>>> Handle( + GetSubtasksQuery request, + CancellationToken cancellationToken) + { + var userId = _currentUserContext.UserId; + if (userId == Guid.Empty) + throw new UnauthorizedAccessException("User context is not available"); + + var parent = await _repository.GetByIdWithIncludesAsync(request.ParentTodoId, cancellationToken) + ?? throw new EntityNotFoundException("TodoItem", request.ParentTodoId); + + // Subtasks belong to top-level tasks only. + if (parent.IsSubtask) + throw new EntityNotFoundException("TodoItem", request.ParentTodoId); + + // Access mirrors GetTodoById: owner, or a friend for a shared/public parent. + var isOwner = parent.UserId == userId; + var hasAccess = isOwner; + if (!isOwner && (parent.IsPublic || parent.SharedWith.Any(s => s.SharedWithUserId == userId))) + { + hasAccess = await _friendshipService.AreFriendsAsync(userId, parent.UserId, cancellationToken); + } + if (!hasAccess) + throw new ForbiddenException("You do not have access to this task"); + + var subtasks = await _repository.GetSubtasksAsync(request.ParentTodoId, cancellationToken); + if (subtasks.Count == 0) + return Result<IReadOnlyList<TodoItemDto>>.Success(Array.Empty<TodoItemDto>()); + + // Subtask completion is GLOBAL: anyone with access marks it done for everyone, so the + // status on the entity is the single source of truth (no per-viewer state). + + // All subtasks share the parent's (owner's) category — fetch it once. + CategoryInfo? categoryInfo = null; + if (parent.CategoryId.HasValue) + { + try + { + categoryInfo = await _categoryGrpcClient.GetCategoryInfoAsync( + parent.CategoryId.Value, parent.UserId, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not enrich subtasks of {ParentId} with category {CategoryId}", + parent.Id, parent.CategoryId.Value); + } + } + + var dtos = subtasks.Select(s => + { + var dto = _mapper.Map<TodoItemDto>(s) with + { + CategoryName = categoryInfo?.Name, + CategoryColor = categoryInfo?.Color, + CategoryIcon = categoryInfo?.Icon, + WorkerCount = s.Workers.Count, + WorkerUserIds = s.Workers.Select(w => w.UserId).ToList(), + // Subtask in-work is per-user and counted for everyone (owner included), so the + // viewer "is working" iff they hold a worker row — no owner exclusion here. + IsWorking = s.Workers.Any(w => w.UserId == userId), + // Completion is global — reflected in Status; no per-viewer flag for subtasks. + IsCompletedByViewer = null, + }; + return dto; + }).ToList(); + + return Result<IReadOnlyList<TodoItemDto>>.Success(dtos); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetTodosByCategory/GetTodosByCategoryQueryHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetTodosByCategory/GetTodosByCategoryQueryHandler.cs index fb2411cc..f8a50a6f 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetTodosByCategory/GetTodosByCategoryQueryHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetTodosByCategory/GetTodosByCategoryQueryHandler.cs @@ -35,7 +35,7 @@ public async Task<Result<PagedResult<TodoItemDto>>> Handle(GetTodosByCategoryQue var (items, totalCount) = await _repository.GetPagedAsync( request.PageNumber, request.PageSize, - t => t.UserId == userId && t.CategoryId == request.CategoryId && !t.IsDeleted, + t => t.UserId == userId && t.CategoryId == request.CategoryId && t.ParentTodoId == null && !t.IsDeleted, t => t.CreatedAt, false, cancellationToken); diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQuery.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQuery.cs index a9c6b3ad..ffd7ca31 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQuery.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQuery.cs @@ -9,5 +9,9 @@ public sealed record GetUserTodosQuery( int PageSize = 10, string? Status = null, Guid? CategoryId = null, - bool? IsCompleted = null) : IQuery<PagedResult<TodoItemDto>>; + bool? IsCompleted = null, + // Subtasks are hidden from every task list by default. The dashboard stats fetch opts in + // (IncludeSubtasks = true) so completed subtasks still count toward weekly statistics — + // the client filters them out of the displayed grid by ParentTodoId. + bool IncludeSubtasks = false) : IQuery<PagedResult<TodoItemDto>>; } diff --git a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQueryHandler.cs b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQueryHandler.cs index 2dec9718..cf6b6cf1 100644 --- a/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQueryHandler.cs +++ b/Services/TodoApi/Planora.Todo.Application/Features/Todos/Queries/GetUserTodos/GetUserTodosQueryHandler.cs @@ -275,9 +275,13 @@ private static Expression<Func<TodoItem, bool>> BuildPredicateWithFriends( var isAskingForActive = request.IsCompleted == false || (requestedStatuses != null && requestedStatuses.Count > 0 && requestedStatuses.All(s => s != TodoStatus.Done)); + var includeSubtasks = request.IncludeSubtasks; + return x => !x.IsDeleted && + // Subtasks live only inside their parent's branch — never in task lists. + (includeSubtasks || x.ParentTodoId == null) && (requestedStatuses == null || requestedStatuses.Contains(x.Status)) && - + // Filter by viewer completion state: // 1. If asking for completed: show globally Done OR viewer-completed // 2. If asking for active: show globally not-Done AND not viewer-completed diff --git a/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItem.cs b/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItem.cs index 13e5418a..dc82d532 100644 --- a/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItem.cs +++ b/Services/TodoApi/Planora.Todo.Domain/Entities/TodoItem.cs @@ -24,6 +24,14 @@ public class TodoItem : BaseEntity, IAggregateRoot public bool IsCompleted => Status == TodoStatus.Done; public DateTime? CompletedAt { get; private set; } public int? RequiredWorkers { get; private set; } + + /// <summary> + /// When set, this item is a subtask: a child node in the parent task's tree. Subtasks + /// are part of their parent, inherit its category/visibility/sharing, never carry a + /// due/expected date, and are hidden from every task list (they live only in the branch). + /// </summary> + public Guid? ParentTodoId { get; private set; } + public bool IsSubtask => ParentTodoId.HasValue; public IReadOnlyCollection<TodoItemTag> Tags => _tags.AsReadOnly(); public IReadOnlyCollection<TodoItemShare> SharedWith => _sharedWith.AsReadOnly(); public IReadOnlyCollection<TodoItemWorker> Workers => _workers.AsReadOnly(); @@ -87,6 +95,74 @@ public static TodoItem Create( return todoItem; } + /// <summary> + /// Create a subtask attached to <paramref name="parent"/>. A subtask is a part of the + /// parent task: it inherits the parent's category, public flag and shared audience, has + /// its own priority/status/title, and never has a due or expected date. Nesting is not + /// allowed — a subtask cannot itself have subtasks. + /// </summary> + public static TodoItem CreateSubtask( + TodoItem parent, + Guid userId, + string title, + string? description, + TodoPriority priority = TodoPriority.Medium) + { + ArgumentNullException.ThrowIfNull(parent); + + if (parent.IsSubtask) + throw new BusinessRuleViolationException("A subtask cannot be nested under another subtask"); + + if (parent.UserId != userId) + throw new BusinessRuleViolationException("Only the parent task owner can add subtasks"); + + if (string.IsNullOrWhiteSpace(title)) + throw new InvalidValueObjectException(nameof(TodoItem), "Title cannot be empty"); + + var subtask = new TodoItem + { + UserId = userId, + Title = title.Trim(), + Description = description?.Trim(), + // Inherited from the parent — never set independently. + CategoryId = parent.CategoryId, + IsPublic = parent.IsPublic, + ParentTodoId = parent.Id, + // Subtasks carry their own priority but never a due/expected date. + Priority = priority, + }; + + // Inherit the parent's shared audience so branch access matches the parent exactly. + subtask.SetSharedWith(parent._sharedWith.Select(s => s.SharedWithUserId), userId); + subtask.MarkAsModified(userId); + + subtask.AddDomainEvent(new TodoItemCreatedDomainEvent( + subtask.Id, + userId, + title, + parent.CategoryId)); + + return subtask; + } + + /// <summary> + /// Re-anchor this subtask's inherited fields (category, public flag, shared audience) to + /// the parent's current values. Called when the parent task changes so the invariant + /// "a subtask is always as visible as its parent" holds. + /// </summary> + public void SyncInheritedFromParent(TodoItem parent, Guid userId) + { + ArgumentNullException.ThrowIfNull(parent); + if (!IsSubtask || ParentTodoId != parent.Id) + throw new BusinessRuleViolationException("Item is not a subtask of the given parent"); + + CategoryId = parent.CategoryId; + IsPublic = parent.IsPublic; + // SetSharedWith also evicts workers who lost access — keeps capacity consistent. + SetSharedWith(parent._sharedWith.Select(s => s.SharedWithUserId), userId); + MarkAsModified(userId); + } + public void UpdateTitle(string title, Guid userId) { if (string.IsNullOrWhiteSpace(title)) @@ -295,7 +371,10 @@ public void SetRequiredWorkers(int? value, Guid userId) public void AddWorker(Guid workerUserId) { - if (workerUserId == UserId) + // On a normal task the owner is implicitly the primary worker, so they never hold a + // worker row. A SUBTASK's "in work" is per-user instead (everyone, owner included, opts + // in independently and is counted), so the owner may be added as a worker there. + if (workerUserId == UserId && !IsSubtask) throw new BusinessRuleViolationException("Owner is always a worker on their own task"); if (_workers.Any(w => w.UserId == workerUserId)) diff --git a/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoRepository.cs b/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoRepository.cs index bc71dad4..e9b40196 100644 --- a/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoRepository.cs +++ b/Services/TodoApi/Planora.Todo.Domain/Repositories/ITodoRepository.cs @@ -35,6 +35,17 @@ Task<IReadOnlyList<TodoItem>> FindWithIncludesAsync( /// </summary> Task<int> GetActiveWorkerTaskCountAsync(Guid userId, CancellationToken cancellationToken = default); + /// <summary> + /// Returns the subtasks (children) of a parent task, oldest first, with includes. Read-only. + /// </summary> + Task<IReadOnlyList<TodoItem>> GetSubtasksAsync(Guid parentTodoId, CancellationToken cancellationToken = default); + + /// <summary> + /// Returns the tracked subtasks of a parent task so they can be mutated (e.g. to propagate + /// the parent's category/visibility changes or to soft-delete them with the parent). + /// </summary> + Task<IReadOnlyList<TodoItem>> GetSubtasksTrackedAsync(Guid parentTodoId, CancellationToken cancellationToken = default); + /// <summary> /// Removes all TodoItemShare rows that represent sharing between two users whose /// friendship has been revoked. Deletes shares in both directions. diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260602111500_AddSubtaskParentTodoId.Designer.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260602111500_AddSubtaskParentTodoId.Designer.cs new file mode 100644 index 00000000..d9a334fa --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260602111500_AddSubtaskParentTodoId.Designer.cs @@ -0,0 +1,336 @@ +// <auto-generated /> +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Planora.Todo.Infrastructure.Persistence; + +#nullable disable + +namespace Planora.Todo.Infrastructure.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20260602111500_AddSubtaskParentTodoId")] + partial class AddSubtaskParentTodoId + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("todo") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Planora.BuildingBlocks.Application.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime?>("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("DeletedBy") + .HasColumnType("uuid"); + + b.Property<string>("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property<bool>("IsDeleted") + .HasColumnType("boolean"); + + b.Property<DateTime?>("NextRetryUtc") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("OccurredOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property<int>("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property<string>("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<DateTime?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOnUtc"); + + b.HasIndex("Status", "OccurredOnUtc"); + + b.ToTable("OutboxMessages", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTime?>("ActualDate") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("CategoryId") + .HasColumnType("uuid"); + + b.Property<DateTime?>("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime?>("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("DeletedBy") + .HasColumnType("uuid"); + + b.Property<string>("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property<DateTime?>("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ExpectedDate") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("Hidden") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<bool>("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<bool>("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<Guid?>("ParentTodoId") + .HasColumnType("uuid"); + + b.Property<int>("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property<int?>("RequiredWorkers") + .HasColumnType("integer"); + + b.Property<string>("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Todo"); + + b.Property<string>("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<DateTime?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("UpdatedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<uint>("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsDeleted"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("ParentTodoId", "IsDeleted", "CreatedAt") + .HasDatabaseName("ix_todo_items_parent_deleted_created"); + + b.HasIndex("UserId", "Status", "IsDeleted", "CreatedAt") + .HasDatabaseName("ix_todo_items_user_status_deleted_created"); + + b.ToTable("TodoItems", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => + { + b.Property<Guid>("TodoItemId") + .HasColumnType("uuid"); + + b.Property<Guid>("SharedWithUserId") + .HasColumnType("uuid"); + + b.HasKey("TodoItemId", "SharedWithUserId"); + + b.HasIndex("SharedWithUserId"); + + b.ToTable("todo_item_shares", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemWorker", b => + { + b.Property<Guid>("TodoItemId") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<DateTime>("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("TodoItemId", "UserId"); + + b.HasIndex("TodoItemId"); + + b.HasIndex("UserId"); + + b.ToTable("todo_item_workers", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.UserTodoViewPreference", b => + { + b.Property<Guid>("ViewerId") + .HasColumnType("uuid"); + + b.Property<Guid>("TodoItemId") + .HasColumnType("uuid"); + + b.Property<bool>("CompletedByViewer") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<DateTime?>("CompletedByViewerAt") + .HasColumnType("timestamp with time zone"); + + b.Property<bool>("HiddenByViewer") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property<Guid?>("ViewerCategoryId") + .HasColumnType("uuid"); + + b.HasKey("ViewerId", "TodoItemId"); + + b.HasIndex("TodoItemId", "ViewerId"); + + b.ToTable("user_todo_view_preferences", "todo"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => + { + b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) + .WithMany() + .HasForeignKey("ParentTodoId") + .OnDelete(DeleteBehavior.NoAction); + + b.OwnsMany("Planora.Todo.Domain.Entities.TodoItemTag", "Tags", b1 => + { + b1.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property<string>("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b1.Property<Guid>("TodoItemId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("TodoItemId"); + + b1.ToTable("todo_tags", "todo"); + + b1.WithOwner() + .HasForeignKey("TodoItemId"); + }); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => + { + b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) + .WithMany("SharedWith") + .HasForeignKey("TodoItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemWorker", b => + { + b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) + .WithMany("Workers") + .HasForeignKey("TodoItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => + { + b.Navigation("SharedWith"); + + b.Navigation("Workers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260602111500_AddSubtaskParentTodoId.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260602111500_AddSubtaskParentTodoId.cs new file mode 100644 index 00000000..00d19763 --- /dev/null +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/20260602111500_AddSubtaskParentTodoId.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Planora.Todo.Infrastructure.Migrations +{ + /// <inheritdoc /> + public partial class AddSubtaskParentTodoId : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<Guid>( + name: "ParentTodoId", + schema: "todo", + table: "TodoItems", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_todo_items_parent_deleted_created", + schema: "todo", + table: "TodoItems", + columns: new[] { "ParentTodoId", "IsDeleted", "CreatedAt" }); + + migrationBuilder.AddForeignKey( + name: "FK_TodoItems_TodoItems_ParentTodoId", + schema: "todo", + table: "TodoItems", + column: "ParentTodoId", + principalSchema: "todo", + principalTable: "TodoItems", + principalColumn: "Id"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TodoItems_TodoItems_ParentTodoId", + schema: "todo", + table: "TodoItems"); + + migrationBuilder.DropIndex( + name: "ix_todo_items_parent_deleted_created", + schema: "todo", + table: "TodoItems"); + + migrationBuilder.DropColumn( + name: "ParentTodoId", + schema: "todo", + table: "TodoItems"); + } + } +} diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs index 8c52192b..5dbe1ef2 100644 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Migrations/TodoDbContextModelSnapshot.cs @@ -18,11 +18,78 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("todo") - .HasAnnotation("ProductVersion", "9.0.15") + .HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Planora.BuildingBlocks.Application.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime?>("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("DeletedBy") + .HasColumnType("uuid"); + + b.Property<string>("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property<bool>("IsDeleted") + .HasColumnType("boolean"); + + b.Property<DateTime?>("NextRetryUtc") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime>("OccurredOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ProcessedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property<int>("RetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property<string>("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<DateTime?>("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOnUtc"); + + b.HasIndex("Status", "OccurredOnUtc"); + + b.ToTable("OutboxMessages", "todo"); + }); + modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => { b.Property<Guid>("Id") @@ -76,6 +143,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property<Guid?>("ParentTodoId") + .HasColumnType("uuid"); + b.Property<int>("Priority") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -122,79 +192,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId", "Status"); + b.HasIndex("ParentTodoId", "IsDeleted", "CreatedAt") + .HasDatabaseName("ix_todo_items_parent_deleted_created"); + b.HasIndex("UserId", "Status", "IsDeleted", "CreatedAt") .HasDatabaseName("ix_todo_items_user_status_deleted_created"); b.ToTable("TodoItems", "todo"); }); - modelBuilder.Entity("Planora.BuildingBlocks.Application.Outbox.OutboxMessage", b => - { - b.Property<Guid>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property<string>("Content") - .IsRequired() - .HasColumnType("text"); - - b.Property<DateTime>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("CreatedBy") - .HasColumnType("uuid"); - - b.Property<DateTime?>("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("DeletedBy") - .HasColumnType("uuid"); - - b.Property<string>("Error") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property<bool>("IsDeleted") - .HasColumnType("boolean"); - - b.Property<DateTime?>("NextRetryUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTime>("OccurredOnUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<DateTime?>("ProcessedOnUtc") - .HasColumnType("timestamp with time zone"); - - b.Property<int>("RetryCount") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0); - - b.Property<string>("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property<string>("Type") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property<DateTime?>("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("UpdatedBy") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProcessedOnUtc"); - - b.HasIndex("Status", "OccurredOnUtc"); - - b.ToTable("OutboxMessages", "todo"); - }); - modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItemShare", b => { b.Property<Guid>("TodoItemId") @@ -265,6 +271,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Planora.Todo.Domain.Entities.TodoItem", b => { + b.HasOne("Planora.Todo.Domain.Entities.TodoItem", null) + .WithMany() + .HasForeignKey("ParentTodoId") + .OnDelete(DeleteBehavior.NoAction); + b.OwnsMany("Planora.Todo.Domain.Entities.TodoItemTag", "Tags", b1 => { b1.Property<Guid>("Id") diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemConfiguration.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemConfiguration.cs index 392f8d9e..dedafa24 100644 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemConfiguration.cs +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Configurations/TodoItemConfiguration.cs @@ -8,9 +8,13 @@ public void Configure(EntityTypeBuilder<TodoItem> builder) { builder.HasKey(x => x.Id); + // 1500 accommodates a subtask's full content (a subtask has no separate body; its + // text lives in the title). Regular-task titles are still held to 200 chars by their + // create validator + UI. On an existing migration-built database this widening is also + // applied at startup (see TodoApi Program.cs) so the column matches this model. builder.Property(x => x.Title) .IsRequired() - .HasMaxLength(200); + .HasMaxLength(1500); builder.Property(x => x.Description) .HasMaxLength(2000); @@ -46,12 +50,26 @@ public void Configure(EntityTypeBuilder<TodoItem> builder) builder.Property(x => x.IsDeleted) .HasDefaultValue(false); + builder.Property(x => x.ParentTodoId) + .IsRequired(false); + builder.HasIndex(x => x.UserId); builder.HasIndex(x => x.CategoryId); builder.HasIndex(x => new { x.UserId, x.Status }); builder.HasIndex(x => new { x.UserId, x.IsDeleted }); builder.HasIndex(x => x.CreatedAt); + // Subtask tree: self-reference to the parent task. NoAction on delete — parent + // deletion soft-deletes children explicitly in the delete handler (cascade would + // not respect the soft-delete model). Indexed for fast "children of X" lookups. + builder.HasIndex(x => new { x.ParentTodoId, x.IsDeleted, x.CreatedAt }) + .HasDatabaseName("ix_todo_items_parent_deleted_created"); + + builder.HasOne<TodoItem>() + .WithMany() + .HasForeignKey(x => x.ParentTodoId) + .OnDelete(DeleteBehavior.NoAction); + // Composite covering index for the most common query: user's non-deleted todos by status, sorted by time builder.HasIndex(x => new { x.UserId, x.Status, x.IsDeleted, x.CreatedAt }) .HasDatabaseName("ix_todo_items_user_status_deleted_created"); diff --git a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoRepository.cs b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoRepository.cs index cc9db491..60cdedf4 100644 --- a/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoRepository.cs +++ b/Services/TodoApi/Planora.Todo.Infrastructure/Persistence/Repositories/TodoRepository.cs @@ -177,6 +177,31 @@ public async Task<IReadOnlyList<TodoItem>> FindWithIncludesAsync( return (items, totalCount); } + public async Task<IReadOnlyList<TodoItem>> GetSubtasksAsync(Guid parentTodoId, CancellationToken cancellationToken = default) + { + return await DbSet + .AsNoTracking() + .AsSplitQuery() + .Where(t => t.ParentTodoId == parentTodoId && !t.IsDeleted) + .OrderBy(t => t.CreatedAt) + .Include(t => t.Tags) + .Include(t => t.SharedWith) + .Include(t => t.Workers) + .ToListAsync(cancellationToken); + } + + public async Task<IReadOnlyList<TodoItem>> GetSubtasksTrackedAsync(Guid parentTodoId, CancellationToken cancellationToken = default) + { + return await DbSet + .AsSplitQuery() + .Where(t => t.ParentTodoId == parentTodoId && !t.IsDeleted) + .OrderBy(t => t.CreatedAt) + .Include(t => t.Tags) + .Include(t => t.SharedWith) + .Include(t => t.Workers) + .ToListAsync(cancellationToken); + } + public async Task<int> GetActiveWorkerTaskCountAsync(Guid userId, CancellationToken cancellationToken = default) { return await DbSet diff --git a/docs/API.md b/docs/API.md index d3b24a85..120622f3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -506,7 +506,7 @@ Update body fields are optional: Rules: -- title required on create, max 200; +- title required on create, max 200 for a regular task; **subtask titles allow up to 1500** (a subtask's whole content lives in its title — see `POST /todos/{id}/subtasks`). The shared update endpoint (`PUT /todos/{id}`) also accepts up to 1500 because subtask renames go through it; - description optional, max 2000 (validators and the EF column agree); - expected date cannot be after due date; - category must belong to current user; diff --git a/docs/architecture.md b/docs/architecture.md index a4aa073e..14faf34b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -181,7 +181,7 @@ RabbitMQ contracts (`IEventBus`, `IIntegrationEventHandler`, `IntegrationEvent`, |---|---| | Todo API | `CategoryDeletedIntegrationEvent`, `UserDeletedIntegrationEvent`, `FriendshipRemovedIntegrationEvent` | | Category API | `UserDeletedIntegrationEvent` | -| Collaboration API | `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent`, `TaskDeletedIntegrationEvent`, `UserDeletedIntegrationEvent` | +| Collaboration API | `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent`, `TaskDeletedIntegrationEvent`, `SubtaskDeletedIntegrationEvent`, `UserDeletedIntegrationEvent` | | Realtime API | `NotificationEvent` | Publishers via outbox: diff --git a/docs/codebase-map.md b/docs/codebase-map.md index 980d71c6..3a2ea330 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -98,7 +98,7 @@ Critical files: - `Features/Todos/Commands/UpdateTodo/UpdateTodoCommandHandler.cs` — publishes `TaskActivityIntegrationEvent` - `Features/Todos/Commands/JoinTodo/JoinTodoCommandHandler.cs` - `Features/Todos/Commands/LeaveTodo/LeaveTodoCommandHandler.cs` -- `Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs` — publishes `TaskDeletedIntegrationEvent` +- `Features/Todos/Commands/DeleteTodo/DeleteTodoCommandHandler.cs` — publishes `TaskDeletedIntegrationEvent` (task) or `SubtaskDeletedIntegrationEvent` (subtask) - `Features/Todos/Commands/SetTodoHidden/SetTodoHiddenCommandHandler.cs` - `Features/Todos/Commands/SetViewerPreference/SetViewerPreferenceCommandHandler.cs` - `Features/Todos/Common/OutboxExtensions.cs` — helper to enqueue integration events in the unit of work @@ -203,6 +203,7 @@ No Realtime EF Core `DbContext` was found. |---|---| | `frontend/src/app` | Next.js App Router pages | | `frontend/src/components` | UI and domain components | +| `frontend/src/hooks` | reusable React hooks (`use-autosave` debounced quick-save, `use-friends`, `use-collapse-scroll`) | | `frontend/src/lib` | API client, auth public client, CSRF, config, analytics, utilities | | `frontend/src/store` | Zustand state | | `frontend/src/types` | frontend DTO/type helpers | diff --git a/docs/database.md b/docs/database.md index b1e37c5d..64666e98 100644 --- a/docs/database.md +++ b/docs/database.md @@ -133,7 +133,7 @@ Default schema: `todo` | Entity | Important fields/indexes | Code | |---|---|---| -| `TodoItem` | title max 200; description max 2000; status stored as string; priority stored as int; user/category ids; `IsPublic`; `Hidden`; `RequiredWorkers` (nullable int, total headcount including owner); soft delete; indexes by user/category/status/delete/created | `Persistence/Configurations/TodoItemConfiguration.cs` | +| `TodoItem` | title max 1500 (a subtask's content lives in its title; regular-task titles stay ≤200 via the create validator + UI); description max 2000; status stored as string; priority stored as int; user/category ids; `IsPublic`; `Hidden`; `RequiredWorkers` (nullable int, total headcount including owner); soft delete; indexes by user/category/status/delete/created. On existing migration-built DBs the `Title` column is widened to `varchar(1500)` at TodoApi startup (idempotent, metadata-only) so it matches the EF model | `Persistence/Configurations/TodoItemConfiguration.cs` | | `TodoTag` | owned table `todo_tags`, tag name max 50 | `TodoItemConfiguration.cs` | | `TodoItemShare` | table `todo_item_shares`, composite key `(TodoItemId, SharedWithUserId)`, index by shared user | `Persistence/Configurations/TodoItemShareConfiguration.cs` | | `TodoItemWorker` | table `todo_item_workers`, composite PK `(TodoItemId, UserId)`, `JoinedAt` default `now()`, cascade FK to `TodoItems`; indexes on `UserId` and `TodoItemId` | `Persistence/Configurations/TodoItemWorkerConfiguration.cs` | @@ -259,7 +259,7 @@ read from `TodoService.CheckTaskCommentAccess` (which now also returns the live ### Event Flow - **Inbound (Inbox):** subscribes to `TaskCreatedIntegrationEvent`, `TaskActivityIntegrationEvent`, - `TaskDeletedIntegrationEvent` (from Todo) and `UserDeletedIntegrationEvent` (from Auth). Replay-safe + `TaskDeletedIntegrationEvent`, `SubtaskDeletedIntegrationEvent` (from Todo) and `UserDeletedIntegrationEvent` (from Auth). Replay-safe (INV-COMM-4): the event bus dedups on the integration event id via the `InboxMessages` table — a redelivered event is skipped before its handler runs, so system comments are never duplicated. - **Outbound (Outbox):** `AddComment` writes a `NotificationEvent` per participant diff --git a/docs/features.md b/docs/features.md index 0c9b9778..bbb0f487 100644 --- a/docs/features.md +++ b/docs/features.md @@ -148,6 +148,13 @@ Organize todos with user-owned labels that carry color, icon, and display order. - Delete returns `403` for forbidden access. - Category deletion emits integration behavior consumed by Todo; see `Services/TodoApi/Planora.Todo.Application/Features/Todos/Events/CategoryDeletedEventHandler.cs`. +### Frontend Behavior + +- Editing an existing category is **quick-save**: there are no Save/Cancel buttons. Changing the name, description, color (color picker), or icon persists automatically. The debounced `useAutosave` hook (`frontend/src/hooks/use-autosave.ts`) coalesces bursts (e.g. dragging the color picker) into a single `PUT`, updates the grid optimistically, and a `AutosaveIndicator` reports `Saving… / All changes saved / Couldn’t save`. +- An empty name is never persisted (a category's only required field); the modal shows an inline "Enter a name to save your changes" hint and skips the save until a name is present. +- Pending edits are flushed when the modal closes (X / `Escape` / backdrop / `Done`), so a change made inside the debounce window is never lost. +- **Creating** a category is the one exception that keeps an explicit `Create category` button: nothing exists to autosave yet, and auto-creating on keystroke would leave half-typed categories behind. There is no Cancel button — closing the modal simply discards the draft. + ## Todos ### Purpose @@ -177,6 +184,62 @@ Create, update, delete, complete, filter, share, hide, and categorize tasks. | Non-owner updates | friend-visible viewer can only change status | | Visibility persistence | `UpdateTodoCommandHandler` loads the entity via `GetByIdWithIncludesTrackedAsync` (with EF Core change tracking) so that changes to `IsPublic` and `SharedWith` collection additions/removals are correctly persisted — tracked loading generates the right INSERT/DELETE DML for the `TodoItemShare` collection, whereas a detached `DbSet.Update()` call would silently emit UPDATE-only SQL against non-existent rows | +### Subtasks + +A **subtask** is a child `TodoItem` (self-referencing `ParentTodoId`) — a part of its parent +task, stored in the parent's tree. Subtasks exist **only inside a task's branch** (the edit modal): +they never appear on the tasks page, the completed page, the dashboard grid, or any list. + +A subtask is **a regular event in the branch timeline**, not a separate panel. It is authored +exactly like the task description — through the compose box's **"+" menu → "Subtask"**, which +switches the same input field into subtask mode (plain Enter adds the step; creating a subtask +**closes the composer**, returning to plain-message mode). Each subtask **forks off the main rail +into its own little sub-branch**: the card and its completion reply sit **offset to the side**, +joined back to the rail by connectors. **There is no creation notification at all** — a subtask +never announces that it was created. + +- the **card** (task-like, same **slide-from-right red delete panel**), offset onto the sub-branch. + Its **completion toggle is the subtask's ONLY marker**, sitting on the sub-branch at the card's + **vertical centre** (not at the top); a state-tinted fork reaches in from the main rail; +- **anyone with access can take a subtask into work** (a Zap toggle on hover). This is **per-user**: + each person joins/leaves independently (server-side worker rows), so one person working never + flips it "in work" for another. Every viewer sees an anonymous **"N working"** presence badge + (amber pill + pulsing dot) — it **never names anyone**; the viewer's own membership reads + "You're working" / "You + N working"; +- when done, a **completion reply** — *another reply on the same sub-branch, with **no rail icon*** + — joined by a soft "└" elbow: "**{Name}** completed sub task · HH:MM" (a nameless "Sub task + completed" shows instantly on optimistic completion, then the name fills in when the folded + system comment lands). + +Subtask **system notifications never get their own icons on the rail** — the only marker the +sub-branch carries is the subtask's own completion toggle. + +| Aspect | Rule | +|---|---| +| Storage | child `TodoItem` with `ParentTodoId`; one level deep (a subtask cannot have subtasks) | +| Category | always inherited from the parent (never set independently) | +| Visibility | public exactly when the parent is public; inherits the parent's shared audience. A parent's category/visibility/sharing change **propagates** to its subtasks | +| Dates | none — a subtask never has a due or expected date | +| Priority | **none in the UX** — a subtask is just a checkable titled step; no priority is shown, chosen, or edited. (The entity still has a priority column, defaulted server-side; it is never surfaced.) | +| Title length | a subtask's whole content lives in its title, so it allows **up to 1500 characters** (regular-task titles stay ≤200). Enforced by `CreateSubtaskCommandValidator` (1500), `UpdateTodoCommandValidator` (1500, shared with subtask renames), the widened `TodoItems.Title` `varchar(1500)` column, and the frontend `SUBTASK_MAX = 1500` (create textarea + inline edit textarea both wrap/grow) | +| Editing | the title is **owner-only** (inline edit in the card; double-click the title or the pencil). Non-owners cannot edit | +| Status / completion | **Completion is global** — anyone with access marks it done/reopens it for everyone (entity status). Stays in the branch after completion (shown done with its completion reply, not removed) | +| In-work (per-user) | **"In work" is per-user, not global** — each participant (the **owner included**) joins/leaves a subtask independently via worker rows (`joinTodo`/`leaveTodo`; subtasks have unlimited capacity and the owner-always-worker rule is relaxed for subtasks). If one user picks it up it is **not** "in work" for another; everyone just sees an anonymous **"N working"** count (`workerCount`), and each viewer's own toggle reflects their `isWorking`. No "started working" notification is emitted for subtasks | +| Lists | excluded from `GetUserTodos`/`GetPublicTodos`/`GetTodosByCategory` (`ParentTodoId == null` filter) | +| Statistics | a **completed** subtask counts toward the **weekly dashboard stat** — the dashboard stats fetch passes `includeSubtasks=true`; active subtasks are filtered out of the active counter and subtasks are never rendered as cards | +| Branch messages | **Creating a subtask emits no event**, and **taking one into work emits no event** (JoinTodo/LeaveTodo skip the activity event for subtasks). **Completing** a subtask still posts `TaskActivityIntegrationEvent` (`SubtaskCompleted`, `Detail` = title) to the **parent's** branch, but **no subtask system comment is ever rendered as a standalone rail node** — `buildFeed` hides *every* "added a subtask: …" / "completed a subtask: …" comment (matched or not, so legacy/renamed ones never reappear) and folds the matched completion into the icon-less reply. The "N working" badge is derived from the subtask's live `workerCount` (polled), so all viewers see it. A subtask has no branch of its own | +| Rendering | a subtask shows only its title (no description), rendered **non-bold** so it reads as a plain branch step, lighter than the Author's Note. A long title **wraps** (the card is flexible-height) rather than being truncated. The subtask forks off the main rail onto a **sub-branch**; its completion toggle is the **only** rail marker; the completion attribution is an **icon-less reply** on the sub-branch (no creation notification at all) | +| Lifecycle | deleting a task soft-deletes its whole subtree. Deleting a **single subtask** also removes the announcement comments it left in the parent's branch — TodoApi emits `SubtaskDeletedIntegrationEvent(parentTaskId, subtaskId, actor, title)` (instead of `TaskDeletedIntegrationEvent`, which would wipe a whole branch); Collaboration's `SubtaskDeletedEventConsumer` soft-deletes the parent-branch system comments whose content ends with `added a subtask: {title}` / `completed a subtask: {title}`. The client also removes them optimistically (suppressing their ids so polling can't re-add them before the cascade lands) | + +Backend: `POST/GET /todos/api/v1/todos/{id}/subtasks` (owner creates; owner/friend lists), +`CreateSubtaskCommand`, `GetSubtasksQuery`; `TodoItem.CreateSubtask` / `SyncInheritedFromParent`; +migration `AddSubtaskParentTodoId`. Frontend: created from the branch "+" menu ("Subtask") via the +shared compose field, and rendered on the rail by `edit-todo-modal/branch-feed.tsx` as a sub-branch +(`SubtaskCard` + the icon-less `SubtaskCompletionReply`; `buildFeed` hides all subtask system +comments and folds the matched completion into `meta`) — completion is global (everyone), **taking +into work is per-user** (worker rows via `joinTodo`/`leaveTodo`, shown as an anonymous "N working" +count to all), inline title edit + delete are owner-only, and there is no priority control. + ### Frontend Behavior - Active todo page loads active tasks in pages of 200. @@ -193,6 +256,7 @@ Create, update, delete, complete, filter, share, hide, and categorize tasks. - On the dashboard, the create panel opens with a softened layout transition and staged field reveal. Its primary plus icon becomes a rotated close action while the panel is open, so the same control pattern can open and close the draft surface. - Toast notifications render on the toast z-index layer and start below the fixed navbar, so completion/update feedback is not hidden behind the header. - The floating navbar quick-creates tasks (title only, private, no category) and dispatches a `planora:task-created` custom DOM event on success. Both the dashboard and todos pages listen for this event to refresh their task lists without a page reload; the dashboard also resets pagination to page 1. +- The Task Branch edit modal (`frontend/src/components/todos/edit-todo-modal`) is **quick-save** with no Save/Cancel buttons: editing the title, priority, due date, category, or visibility/sharing autosaves via the debounced `useAutosave` hook. Owners persist the full task payload; a shared viewer who can manage their own category autosaves only their private category preference. The description ("Author's Note" in the branch) keeps its own explicit editor and is intentionally excluded from the autosave equality check so it is never written twice. There is **no footer panel** — no autosave-status indicator and no `Done` button; the modal closes via the header **✕**, the backdrop, or `Escape`, and a pending edit is flushed on close/unmount. Save failures are toasted once; the autosave retries on the next edit. - When the user removes a category during task edit, `applyCategoryPatch` (`frontend/src/utils/todo-utils.ts`) zeroes all four category fields (`categoryId`, `categoryName`, `categoryColor`, `categoryIcon`) in local state after the PUT — necessary because the backend treats `categoryId: null` as a no-op and echoes back the old values. - Todo, dashboard, and completed-task pages enrich author names for public friend tasks as well as direct shared tasks. @@ -334,7 +398,7 @@ so Collaboration needs no extra lookup to render the sentence. Consumers are ide - **Live updates without re-opening:** there is no realtime socket, so the feed merges the newest page on a 5 s interval (paused while editing) and reconciles by comment id — new messages, edits, and the system status-comments appear on their own. After a take/leave/complete action it additionally schedules short catch-up merges (≈0.6 / 1.5 / 3 s); combined with the signal-driven outbox dispatch (see Architecture above), the status system-comment now shows in well under a second. The merge only re-pins to the bottom when the reader was already there. - **Non-owner date popover:** a viewer who is not the task owner sees the priority/date/visibility tokens read-only. The date popover omits the Today/Tomorrow/+3 days/Next week quick-pick row entirely (not merely disabled) — only the read-only calendar remains. - **Sticky Author's Note:** the pinned card lives at the top of the scrollable rail and scrolls away with content. Once it passes out of view the feed shows a condensed frosted-glass bar (author avatar + truncated first line + animated chevron) at the top of the feed area. Clicking the condensed bar smoothly scrolls back to the full card and fires a violet attention pulse (`genesis_highlight` keyframe) so the note is easy to spot. The bar animates in/out with a spring (Framer Motion `AnimatePresence`). -- **Compose "+" menu actions:** the menu is visible to all participants (owner and collaborators). The "Description" attachment item is shown only to the task owner and is muted once a description exists (already added). Two additional action items are shown to everyone: +- **Compose "+" menu actions:** the menu is visible to all participants (owner and collaborators). The "Description" attachment item is shown only to the task owner and is muted once a description exists (already added); the owner also gets a "Subtask" item (authored in the same compose field). **Once the task is completed the menu offers nothing** — description, subtask, and the take/complete actions are all hidden, so the "+" simply doesn't open on a done task. Two action items are otherwise shown to everyone: - **Take into work / Leave task** — mirrors the existing join/leave flow exactly (owner: `status → inProgress` / `status → todo`; viewer: `joinTodo` / `leaveTodo`). The button label and icon flip between "Take into work" (Zap, indigo) and "Leave task" (LogOut, red) depending on the current in-progress state. An optimistic `workOverride` state in the modal flips the pill in the header bar instantly before the parent refetch arrives. Leaving — via this menu **or** the header pill — keeps the modal open so the "left the task" event is read in place. - **Complete task / Reopen task** — mirrors the existing complete flow (owner: `status → done`; viewer: `completedByViewer` toggle). Closes the modal on success. diff --git a/docs/security-idor-coverage.md b/docs/security-idor-coverage.md index 7e49a995..a96ee9be 100644 --- a/docs/security-idor-coverage.md +++ b/docs/security-idor-coverage.md @@ -56,6 +56,9 @@ that ship in `tests/Planora.UnitTests/`** — verified at commit time. | `PATCH /todos/api/v1/todos/{id}/viewer-preferences` | UserTodoViewPreference | Same as hidden — viewer scope is the current user. | covered by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs` | | `POST /todos/api/v1/todos/{id}/join` | TodoItemWorker | Viewer joins an open public todo. IDOR not applicable (visibility predicate is the gate). | covered by `Services/TodoApi/Domain/TodoItemWorkerTests.cs` (capacity/eviction) + handler access checks | | `POST /todos/api/v1/todos/{id}/leave` | TodoItemWorker | Viewer leaves; row keyed on `(userId, todoId)`. | covered by `Services/TodoApi/Domain/TodoItemWorkerTests.cs` | +| `GET /todos/api/v1/todos/{id}/subtasks` | TodoItem (children) | Caller must see the parent: owner OR shared-with-current-user OR public+friend. `GetSubtasksQueryHandler` mirrors the `GetTodoById` visibility predicate; per-viewer completion applied. | pinned by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs::GetSubtasks_RejectsViewerWithoutAccessToPrivateParent` (+ `GetSubtasks_ReturnsChildrenForOwner`) | +| `POST /todos/api/v1/todos/{id}/subtasks` | TodoItem (child) | Owner-only; the parent (from the route) must belong to the caller and not itself be a subtask. Inherits the parent's category/visibility/sharing. | pinned by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs::CreateSubtask_RejectsForeignParent` (+ `CreateSubtask_RejectsNestingUnderSubtask`) | +| `PUT /todos/api/v1/todos/{id}` (subtask) | TodoItem (child) | Editing a subtask's title/priority is **owner-only**; completing/reopening is allowed for anyone who can see the parent and applies **globally** (entity status, not a per-viewer row). | pinned by `Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs::UpdateTodo_NonOwnerCompletesSubtask_GloballyNotPerViewer` (+ `UpdateTodo_NonOwnerCannotEditSubtaskTitleOrPriority`) | ## Collaboration API diff --git a/frontend/src/app/categories/page.tsx b/frontend/src/app/categories/page.tsx index f3468bb7..e7a37275 100644 --- a/frontend/src/app/categories/page.tsx +++ b/frontend/src/app/categories/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState, useCallback } from "react" +import { useEffect, useMemo, useRef, useState, useCallback } from "react" import { useRouter } from "next/navigation" import { motion, AnimatePresence } from "framer-motion" import { SPRING_STANDARD, TWEEN_UI } from "@/lib/animations" @@ -14,6 +14,8 @@ import { Category, type CategoryListResponse, toCategoryList } from "@/types/cat import { ICON_PICKER_ITEMS } from "@/components/ui/icon-picker" import { ConfirmDialog } from "@/components/ui/confirm-dialog" import { ModalPortal } from "@/components/ui/modal-portal" +import { AutosaveIndicator } from "@/components/ui/autosave-indicator" +import { useAutosave } from "@/hooks/use-autosave" import { ColorPicker } from "@/components/todos/edit-todo-modal/color-picker" import { cn, truncateText } from "@/lib/utils" import { ICON_MAP } from "@/lib/icon-map" @@ -208,12 +210,15 @@ function CategoryModal({ onSave, initialData, title, + autosave = false, }: { isOpen: boolean onClose: () => void onSave: (data: CategoryFormData) => Promise<void> initialData?: CategoryFormData title: string + /** Edit mode: persist every change automatically with no Save/Cancel buttons. */ + autosave?: boolean }) { const [name, setName] = useState(initialData?.name ?? "") const [desc, setDesc] = useState(initialData?.description ?? "") @@ -222,18 +227,56 @@ function CategoryModal({ const [saving, setSaving] = useState(false) const [error, setError] = useState("") + // Live form snapshot, trimmed exactly as it is persisted, used as the autosave value. + const formData: CategoryFormData = useMemo( + () => ({ name: name.trim(), description: desc.trim(), color, icon }), + [name, desc, color, icon], + ) + + const { status: saveStatus, flush, reset } = useAutosave<CategoryFormData>({ + value: formData, + enabled: autosave && isOpen, + onSave, + // Never persist an empty name (the category's only required field). + validate: (data) => data.name.length > 0, + }) + /** - * Reset form when modal opens/closes + * Seed the form (and re-anchor the autosave baseline) only on the modal's *opening* + * edge. `initialData` is a fresh object on every parent render, and autosave triggers + * parent re-renders (optimistic grid updates) while the modal is open — so resetting on + * its identity would clobber in-progress input. Gating on the open transition avoids that + * while still re-seeding correctly when a different category is opened (open requires a + * prior close, since the modal is a full-screen overlay). */ + const wasOpen = useRef(false) useEffect(() => { - if (isOpen) { - setName(initialData?.name ?? "") - setDesc(initialData?.description ?? "") - setColor(initialData?.color ?? "#6366f1") - setIcon(initialData?.icon ?? null) + if (isOpen && !wasOpen.current) { + const next = { + name: initialData?.name ?? "", + description: initialData?.description ?? "", + color: initialData?.color ?? "#6366f1", + icon: initialData?.icon ?? null, + } + setName(next.name) + setDesc(next.description) + setColor(next.color) + setIcon(next.icon) setError("") + reset({ ...next, name: next.name.trim(), description: next.description.trim() }) } - }, [isOpen, initialData]) + wasOpen.current = isOpen + }, [isOpen, initialData, reset]) + + /** + * Flush any pending autosave, then close. Guarantees the last edit is persisted even + * if the user closes within the debounce window (the modal stays mounted between opens, + * so we cannot rely on an unmount flush here). + */ + const handleClose = useCallback(() => { + if (autosave) void flush() + onClose() + }, [autosave, flush, onClose]) /** * Close on Escape key @@ -241,15 +284,15 @@ function CategoryModal({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - onClose() + handleClose() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [onClose]) + }, [handleClose]) /** - * Handle save + * Handle explicit create (create mode only — editing autosaves). */ const handleSave = async () => { if (!name.trim()) { @@ -259,12 +302,7 @@ function CategoryModal({ setSaving(true) try { - await onSave({ - name: name.trim(), - description: desc.trim(), - color, - icon, - }) + await onSave(formData) onClose() } catch { setError("Failed to save category") @@ -282,7 +320,7 @@ function CategoryModal({ {isOpen && ( <div className="fixed inset-0 z-[2000] flex items-center justify-center p-4" - onClick={onClose} + onClick={handleClose} > {/* Backdrop */} <motion.div @@ -318,7 +356,7 @@ function CategoryModal({ </p> </div> <motion.button - onClick={onClose} + onClick={handleClose} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.95 }} className="h-10 w-10 rounded-2xl bg-gray-50 hover:bg-gray-100 flex items-center justify-center transition-[background-color] active:shadow-md" @@ -414,7 +452,7 @@ function CategoryModal({ </motion.div> <AnimatePresence> - {error && ( + {(error || (autosave && !name.trim())) && ( <motion.p initial={{ opacity: 0, y: -6, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} @@ -422,7 +460,7 @@ function CategoryModal({ transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }} className="rounded-xl border border-red-100 bg-red-50 px-3 py-2.5 text-center text-[11px] font-bold text-red-600" > - {error} + {error || "Enter a name to save your changes"} </motion.p> )} </AnimatePresence> @@ -478,31 +516,40 @@ function CategoryModal({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: 0.25 }} - className="mt-6 flex flex-col-reverse gap-3 border-t border-gray-100 pt-5 sm:flex-row" + className="mt-6 flex items-center gap-3 border-t border-gray-100 pt-5" > - <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} className="sm:flex-1"> - <Button - variant="outline" - className="h-12 w-full rounded-2xl border-gray-100 font-bold text-gray-500 hover:bg-gray-50" - onClick={onClose} - disabled={saving} - > - Cancel - </Button> - </motion.div> - <motion.div - whileHover={!saving && name.trim() ? { scale: 1.02 } : undefined} - whileTap={!saving && name.trim() ? { scale: 0.98 } : undefined} - className="sm:flex-1" - > - <Button - className="h-12 w-full rounded-2xl bg-black font-bold shadow-xl shadow-black/10 hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none" - onClick={handleSave} - disabled={saving || !name.trim()} + {autosave ? ( + // Edit mode: no Save/Cancel — changes persist automatically. The indicator + // confirms each save; "Done" simply closes the (already-saved) modal. + <> + <AutosaveIndicator status={saveStatus} /> + <div className="flex-1" /> + <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> + <Button + variant="outline" + className="h-12 rounded-2xl border-gray-100 px-6 font-bold text-gray-500 hover:bg-gray-50" + onClick={handleClose} + > + Done + </Button> + </motion.div> + </> + ) : ( + // Create mode: a single explicit Create action (nothing exists to autosave yet). + <motion.div + whileHover={!saving && name.trim() ? { scale: 1.02 } : undefined} + whileTap={!saving && name.trim() ? { scale: 0.98 } : undefined} + className="flex-1" > - {saving ? "Saving..." : "Save"} - </Button> - </motion.div> + <Button + className="h-12 w-full rounded-2xl bg-black font-bold shadow-xl shadow-black/10 hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none" + onClick={handleSave} + disabled={saving || !name.trim()} + > + {saving ? "Creating..." : "Create category"} + </Button> + </motion.div> + )} </motion.div> </div> </motion.div> @@ -596,14 +643,18 @@ export default function CategoriesPage() { } /** - * Edit existing category + * Autosave an edit to the open category. Called by the modal on every committed + * change (debounced), so it stays quiet on success and updates the grid optimistically + * rather than refetching per keystroke. Errors are toasted and re-thrown so the modal's + * AutosaveIndicator can show the failed state and retry on the next change. */ const handleEdit = async (data: CategoryFormData) => { if (!editingCategory) return + const id = editingCategory.id try { await api.put( - `/categories/api/v1/categories/${editingCategory.id}`, + `/categories/api/v1/categories/${id}`, { name: data.name, description: data.description || null, @@ -611,11 +662,17 @@ export default function CategoriesPage() { icon: data.icon, } ) - await fetchCategories() - addToast({ type: "success", title: "Category updated" }) + setCategories((prev) => + prev.map((c) => + c.id === id + ? { ...c, name: data.name, description: data.description, color: data.color, icon: data.icon } + : c + ) + ) } catch (error) { console.error("Failed to update category:", error) - addToast({ type: "error", title: "Failed to update category" }) + addToast({ type: "error", title: "Failed to save category" }) + throw error } } @@ -719,6 +776,7 @@ export default function CategoriesPage() { <CategoryModal isOpen={!!editingCategory} + autosave onClose={() => setEditingCategory(null)} onSave={handleEdit} initialData={editingCategory ? { diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 99786da2..c9ec185c 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -182,7 +182,10 @@ export default function DashboardPage() { const fetchStats = useCallback(async (signal?: AbortSignal) => { try { const res = await api.get<{ items: Todo[] }>("/todos/api/v1/todos", { - params: { pageNumber: 1, pageSize: 1000 }, + // includeSubtasks: subtasks are hidden from every list, but completed subtasks must + // still count toward the weekly statistics below (they are filtered out of the active + // counter and never rendered as cards). + params: { pageNumber: 1, pageSize: 1000, includeSubtasks: true }, signal, }) if (signal?.aborted) return @@ -298,17 +301,20 @@ export default function DashboardPage() { }, [isAuthenticated, hasHydrated, mounted, fetchTodos, fetchStats, fetchCategories, clearAuth, router]) const activeStatsTodos = useMemo(() => - statsTodos.filter(t => { - const s = String(t.status).toLowerCase(); + statsTodos.filter(t => { + if (t.parentTodoId) return false; // subtasks never inflate the active-task counter + const s = String(t.status).toLowerCase(); const isDone = s === "done" || s === "completed"; return !isDone && t.isCompletedByViewer !== true; }), [statsTodos] ) + // Completed-this-week deliberately includes completed subtasks so finishing a step in a + // task's branch contributes to the weekly statistics. const allCompletedStatsTodos = useMemo(() => - statsTodos.filter(t => { - const s = String(t.status).toLowerCase(); + statsTodos.filter(t => { + const s = String(t.status).toLowerCase(); const isDone = s === "done" || s === "completed"; return isDone || t.isCompletedByViewer === true; }), @@ -454,9 +460,12 @@ export default function DashboardPage() { t.id !== todoId ? t : { ...merged, authorName: t.authorName ?? friendNameCache.current.get(updated.userId) } setTodos(prev => prev.map(applyMerge)) setStatsTodos(prev => prev.map(applyMerge)) - setEditingTodo(null) - addToast({ type: "success", title: "Task updated" }) - } catch { addToast({ type: "error", title: "Failed to update" }) } + // Autosave path: keep the modal open and quiet (the in-modal indicator confirms it); + // do not refresh `editingTodo` so the open modal's local field state is never clobbered. + } catch (error) { + addToast({ type: "error", title: "Failed to save changes" }) + throw error // surface the error state in the modal's autosave indicator + } } const handleSaveViewerPreference = useCallback(async (todoId: string, viewerCategoryId: string | null) => { @@ -475,10 +484,10 @@ export default function DashboardPage() { setTodos(prev => prev.map(t => t.id === todoId ? { ...t, ...enriched } : t)) setStatsTodos(prev => prev.map(t => t.id === todoId ? { ...t, ...enriched } : t)) - setEditingTodo(null) - addToast({ type: "success", title: "Your category was saved" }) - } catch { + // Autosave path: stay open and quiet; the modal's AutosaveIndicator confirms the save. + } catch (error) { addToast({ type: "error", title: "Failed to save your category" }) + throw error // surface the error state in the modal's autosave indicator } }, [todos, statsTodos, addToast]) diff --git a/frontend/src/app/tasks/completed/page.tsx b/frontend/src/app/tasks/completed/page.tsx index ff61200a..d811b271 100644 --- a/frontend/src/app/tasks/completed/page.tsx +++ b/frontend/src/app/tasks/completed/page.tsx @@ -247,11 +247,11 @@ export default function CompletedTasksPage() { const authorName = existing.authorName ?? friendNameCache.current.get(updated.userId) setTodos((prev) => prev.map((t) => (t.id === todoId ? { ...updated, authorName } : t))) - setEditingTodo(null) - addToast({ type: "success", title: "Task updated" }) + // Autosave path: keep the modal open and quiet; the in-modal indicator confirms the save. } catch (error) { console.error("Failed to update todo:", error) - addToast({ type: "error", title: "Failed to update task" }) + addToast({ type: "error", title: "Failed to save changes" }) + throw error // surface the error state in the modal's autosave indicator } } @@ -270,11 +270,11 @@ export default function CompletedTasksPage() { const enriched = authorName ? { ...fullTask, authorName } : fullTask setTodos((prev) => prev.map((t) => (t.id === todoId ? { ...t, ...enriched } : t))) - setEditingTodo(null) - addToast({ type: "success", title: "Your category was saved" }) + // Autosave path: stay open and quiet; the modal's AutosaveIndicator confirms the save. } catch (error) { console.error("Failed to update viewer preference:", error) addToast({ type: "error", title: "Failed to save your category" }) + throw error // surface the error state in the modal's autosave indicator } }, [todos, addToast]) diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 36e866be..b29b1fb5 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -381,11 +381,14 @@ export default function TasksPage() { setTodos((prev) => prev.map((t) => (t.id === id ? nextTodo : t))) } - setEditingTodo(null) - addToast({ type: "success", title: "Task updated" }) + // Autosave path: keep the modal open and stay quiet — the in-modal AutosaveIndicator + // reports success. We intentionally do not refresh `editingTodo` so the open modal's + // local field state (the source of truth while editing) is never clobbered mid-edit. } catch (error) { console.error("Failed to update todo:", error) - addToast({ type: "error", title: "Failed to update task" }) + addToast({ type: "error", title: "Failed to save changes" }) + // Re-throw so the modal's autosave surfaces the error state and retries on next edit. + throw error } } @@ -409,11 +412,11 @@ export default function TasksPage() { setTodos((prev) => prev.map((t) => (t.id === id ? nextTodo : t))) } - setEditingTodo(null) - addToast({ type: "success", title: "Your category was saved" }) + // Autosave path: stay open and quiet; the modal's AutosaveIndicator confirms the save. } catch (error) { console.error("Failed to update viewer preference:", error) addToast({ type: "error", title: "Failed to save your category" }) + throw error // surface error state in the modal's autosave indicator } } @@ -593,14 +596,20 @@ export default function TasksPage() { </div> </div> - <AnimatePresence mode="wait"> - {!isCreateOpen ? ( + {/* The quick-filter plate and the create panel are two INDEPENDENT presences (not a single + `mode="wait"` swap). A `mode="wait"` swap dropped the filter's enter animation when the + create panel closed on submit, because `handleCreate` flips `isCreateOpen` and then + immediately re-renders the page via `fetchActiveTodos` (`setLoading`), interrupting the + deferred enter and leaving the filter collapsed at height 0. Decoupling them makes each + presence self-contained, so the filter always re-reveals after a task is created. */} + <AnimatePresence initial={false}> + {!isCreateOpen && ( <motion.div key="quick-filter" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} - transition={{ duration: 0.2, ease: [0.4, 0, 1, 1] }} + transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="overflow-hidden" > <QuickFilterBar @@ -610,7 +619,10 @@ export default function TasksPage() { onClear={() => handleFilterChange([])} /> </motion.div> - ) : ( + )} + </AnimatePresence> + <AnimatePresence initial={false}> + {isCreateOpen && ( <motion.div key="create-panel" initial={{ opacity: 0, height: 0 }} diff --git a/frontend/src/components/todos/edit-todo-modal/branch-feed.tsx b/frontend/src/components/todos/edit-todo-modal/branch-feed.tsx index 0ba630fb..62f685c6 100644 --- a/frontend/src/components/todos/edit-todo-modal/branch-feed.tsx +++ b/frontend/src/components/todos/edit-todo-modal/branch-feed.tsx @@ -2,9 +2,14 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react" import { motion, AnimatePresence } from "framer-motion" -import { Pencil, Trash2, Send, Plus, FileText, X, ChevronUp, Zap, LogOut, CheckCircle2, Loader2, Check, Play, Circle, type LucideIcon } from "lucide-react" -import { fetchComments, addComment, updateComment, deleteComment, getApiErrorMessage } from "@/lib/api" -import type { TodoComment } from "@/types/todo" +import { Pencil, Trash2, Send, Plus, FileText, X, ChevronUp, Zap, Pause, LogOut, CheckCircle2, Loader2, Check, Play, Circle, ListTree, type LucideIcon } from "lucide-react" +import { + fetchComments, addComment, updateComment, deleteComment, + fetchSubtasks, createSubtask, updateSubtask, deleteSubtask, + joinTodo, leaveTodo, + getApiErrorMessage, +} from "@/lib/api" +import type { TodoComment, Todo } from "@/types/todo" import { SPRING_STANDARD } from "@/lib/animations" import { FriendAvatar } from "./friend-avatar" import { @@ -14,6 +19,10 @@ import { const COMMENT_MAX = 2000 const GENESIS_MAX = 5000 +const SUBTASK_MAX = 1500 + +// Snappy spring for subtask micro-interactions (toggle pop, card enter/exit). +const SPRING_SNAP = { type: "spring" as const, stiffness: 460, damping: 32 } // Activity-rail geometry. The rail line is centred at RAIL_CENTER within a wrapper that pads its // content by RAIL_GUTTER, and every marker is centred on that same x so avatars and system-event @@ -26,6 +35,7 @@ const RAIL_CENTER = 20 // Markers are intentionally greyscale (see SystemEvent) so the rail stays calm and uncluttered. function getSystemEventIcon(content: string): LucideIcon { const t = content.toLowerCase() + if (t.includes("subtask") || t.includes("под-задач") || t.includes("подзадач")) return ListTree if (t.includes("complet") || t.includes("завершил") || t.includes("выполнил")) return Check if (t.includes("start") || t.includes("working") || t.includes("взял в работу") || t.includes("присоединил")) return Play if (t.includes("left") || t.includes("leav") || t.includes("покинул") || t.includes("приостановил")) return LogOut @@ -33,6 +43,19 @@ function getSystemEventIcon(content: string): LucideIcon { return Circle } +// Completion is global: anyone with access marks a subtask done for everyone, so the entity status +// is the single source of truth (no per-viewer state). +function isSubtaskDone(s: Todo): boolean { + const st = String(s.status).toLowerCase() + return st === "done" || st === "completed" +} +// "In work" is per-user — derived from worker rows, not status: +// • workerCount = how many people are working on it (shown to everyone, anonymously); +// • isWorking = whether THIS viewer is one of them (drives their own toggle). +function subtaskWorkerCount(s: Todo): number { + return s.workerCount ?? 0 +} + interface BranchFeedProps { todoId: string isOwner: boolean @@ -54,21 +77,87 @@ interface BranchFeedProps { onCompleteTask?: () => Promise<void> } -// Flat list item (day separator or comment) +// Compose modes — a plain branch message, the task description, or a new subtask. Subtasks are +// authored exactly like the description: pick it from the "+" menu, type into the same field, send. +type ComposeMode = "text" | "description" | "subtask" + +// Completion attribution for a subtask, parsed from its (now hidden) "completed a subtask" system +// comment and rendered as a no-icon reply in the subtask's sub-branch. Creation is intentionally +// NOT surfaced — a subtask never shows a "created" notification. +interface SubtaskMeta { + completedBy?: string + completedAt?: string +} + +// Flat list item (day separator, comment, or an inline subtask card cluster) type FeedItem = | { type: "separator"; label: string; key: string } | { type: "comment"; comment: TodoComment } + | { type: "subtask"; subtask: Todo; meta: SubtaskMeta } + +// Pulls the actor's name out of a subtask system sentence, e.g. "Ann Lee completed a subtask: Buy milk". +function parseSubtaskActor(content: string, verb: "added" | "completed"): string | undefined { + const m = content.match(new RegExp(`^(.*?)\\s+${verb} a subtask:`, "i")) + const name = m?.[1]?.trim() + return name || undefined +} + +// True for any subtask lifecycle system comment ("… added a subtask: …" / "… completed a subtask: +// …"). These are NEVER rendered as standalone rail nodes — not even legacy ones whose subtask was +// renamed or deleted — so the old "X completed a subtask: <long title>" lines never reappear. +function isSubtaskSystemComment(c: TodoComment): boolean { + return !!c.isSystemComment && /(?:added|completed) a subtask:/i.test(c.content) +} + +// Builds the chronological rail by interleaving branch comments with subtask card clusters. A +// subtask's lifecycle system comments are hidden from the rail entirely: creation is never shown, +// and completion is parsed (when it can be matched to a live subtask) into the icon-less completion +// reply inside the subtask's own sub-branch. Cards anchor on their own creation time. +function buildFeed(comments: TodoComment[], subtasks: Todo[]): FeedItem[] { + const completionEvents = comments.filter((c) => c.isSystemComment && /completed a subtask:/i.test(c.content)) + + const usedComplete = new Set<string>() + const metaById = new Map<string, SubtaskMeta>() + + // Match in creation order for stable greedy pairing when titles repeat. + const subsByTime = [...subtasks].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) + for (const s of subsByTime) { + const suffix = `: ${s.title}` + const meta: SubtaskMeta = {} + + const completed = completionEvents.find((c) => !usedComplete.has(c.id) && c.content.trimEnd().endsWith(suffix)) + if (completed) { + usedComplete.add(completed.id) + meta.completedBy = parseSubtaskActor(completed.content, "completed") + meta.completedAt = completed.createdAt + } + + metaById.set(s.id, meta) + } + + type Ev = + | { time: string; order: number; kind: "comment"; comment: TodoComment } + | { time: string; order: number; kind: "subtask"; subtask: Todo } + const evs: Ev[] = [] + for (const c of comments) { + if (isSubtaskSystemComment(c)) continue // never a standalone rail node (matched or not) + evs.push({ time: c.createdAt, order: 0, kind: "comment", comment: c }) + } + // The subtask card anchors on its own creation time; order:1 keeps it after same-time comments. + for (const s of subtasks) evs.push({ time: s.createdAt, order: 1, kind: "subtask", subtask: s }) + evs.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : a.order - b.order)) -function buildFeed(comments: TodoComment[]): FeedItem[] { const items: FeedItem[] = [] let lastDay = "" - for (const c of comments) { - const day = formatDayLabel(c.createdAt) + let sepIdx = 0 + for (const e of evs) { + const day = formatDayLabel(e.time) if (day !== lastDay) { - items.push({ type: "separator", label: day, key: `sep-${c.createdAt}` }) + items.push({ type: "separator", label: day, key: `sep-${sepIdx++}-${e.time}` }) lastDay = day } - items.push({ type: "comment", comment: c }) + if (e.kind === "comment") items.push({ type: "comment", comment: e.comment }) + else items.push({ type: "subtask", subtask: e.subtask, meta: metaById.get(e.subtask.id) ?? {} }) } return items } @@ -79,6 +168,7 @@ export function BranchFeed({ onStartWork, onStopWork, onCompleteTask, }: BranchFeedProps) { const [comments, setComments] = useState<TodoComment[]>([]) + const [subtasks, setSubtasks] = useState<Todo[]>([]) const [totalCount, setTotalCount] = useState(0) const [page, setPage] = useState(1) const [loading, setLoading] = useState(true) @@ -89,13 +179,15 @@ export function BranchFeed({ const [editingGenesis, setEditingGenesis] = useState(false) const [genesisEditContent, setGenesisEditContent] = useState("") const [error, setError] = useState<string | null>(null) - const [composeMode, setComposeMode] = useState<"text" | "description">("text") + const [composeMode, setComposeMode] = useState<ComposeMode>("text") const [plusMenuOpen, setPlusMenuOpen] = useState(false) // Pinned Author's Note: condensed sticky header is shown once the full card scrolls away. const [genesisOutOfView, setGenesisOutOfView] = useState(false) const [genesisHighlight, setGenesisHighlight] = useState(false) // Which task action (if any) is currently awaiting its async handler. const [actionPending, setActionPending] = useState<null | "work" | "complete">(null) + // Per-subtask in-flight guard so double-clicks can't race (and polling won't clobber optimism). + const [subtaskPending, setSubtaskPending] = useState<Set<string>>(new Set()) const feedRef = useRef<HTMLDivElement>(null) const composeRef = useRef<HTMLTextAreaElement>(null) @@ -108,13 +200,27 @@ export function BranchFeed({ const pinBottomRef = useRef(false) // Total comment count, mirrored in a ref so polling can target the last page without re-creating the timer. const totalCountRef = useRef(0) + // Subtasks currently mid-write — polling must not revert their optimistic state. + const subtaskPendingRef = useRef<Set<string>>(new Set()) + // System-comment ids hidden client-side after a subtask delete, until the async cascade lands — + // keeps the deleted subtask's announcement(s) from flickering back via polling/reloads. + const suppressedCommentIds = useRef<Set<string>>(new Set()) // Pending post-action catch-up reloads (the status system-comment is produced asynchronously). const retryTimers = useRef<ReturnType<typeof setTimeout>[]>([]) + const setSubtaskBusy = (id: string, on: boolean) => { + setSubtaskPending((prev) => { + const next = new Set(prev) + if (on) next.add(id); else next.delete(id) + subtaskPendingRef.current = next + return next + }) + } + const load = useCallback(async (pageNum: number, replace: boolean, pin: boolean = replace) => { try { const res = await fetchComments(todoId, pageNum, 50) - const items = res.items ?? [] + const items = (res.items ?? []).filter((c) => !suppressedCommentIds.current.has(c.id)) setTotalCount(res.totalCount ?? 0) totalCountRef.current = res.totalCount ?? 0 setComments((prev) => (replace ? items : [...items, ...prev])) @@ -126,6 +232,41 @@ export function BranchFeed({ } }, [todoId]) + // Fetch the parent's subtasks and merge by id, so server truth flows in (other users' edits, + // completions, the inherited category) without flickering away an in-flight optimistic change. + const loadSubtasks = useCallback(async () => { + try { + const list = await fetchSubtasks(todoId) + setSubtasks((prev) => { + const pending = subtaskPendingRef.current + const prevById = new Map(prev.map((s) => [s.id, s])) + const byId = new Map<string, Todo>() + let changed = false + for (const s of list) { + const existing = prevById.get(s.id) + // Keep the optimistic copy for rows we're still writing. + if (existing && pending.has(s.id)) { byId.set(s.id, existing); continue } + // Refresh on any change a viewer must see — including another participant's worker count + // (so "N working" updates live) and the viewer's own membership. + if (!existing || existing.status !== s.status || existing.title !== s.title || + (existing.workerCount ?? 0) !== (s.workerCount ?? 0) || !!existing.isWorking !== !!s.isWorking) { + changed = true + } + byId.set(s.id, s) + } + // Preserve optimistic-only rows the server hasn't returned yet (just-created). + for (const s of prev) { + if (!byId.has(s.id) && pending.has(s.id)) byId.set(s.id, s) + else if (!byId.has(s.id) && !list.some((l) => l.id === s.id)) changed = true + } + if (!changed && byId.size === prev.length) return prev + return Array.from(byId.values()).sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) + }) + } catch { + /* a missing/forbidden parent simply yields no subtasks */ + } + }, [todoId]) + const isAtBottom = () => { const el = feedRef.current if (!el) return true @@ -150,6 +291,8 @@ export function BranchFeed({ const byId = new Map(prev.map((c) => [c.id, c])) let changed = false for (const c of items) { + // A subtask just deleted client-side: keep its announcement hidden until the cascade lands. + if (suppressedCommentIds.current.has(c.id)) { if (byId.delete(c.id)) changed = true; continue } const existing = byId.get(c.id) if (!existing) { byId.set(c.id, c); changed = true } else if (existing.content !== c.content || existing.updatedAt !== c.updatedAt) { @@ -168,29 +311,27 @@ export function BranchFeed({ } }, [todoId]) - useEffect(() => { load(1, true) }, [load, refreshKey]) + useEffect(() => { load(1, true); void loadSubtasks() }, [load, loadSubtasks, refreshKey]) // After an action bumps refreshKey, the resulting status system-comment is materialised // asynchronously (Outbox → Inbox). Schedule a few short catch-up merges so it appears within - // ~1–2s without the user re-opening the modal. + // ~1–2s without the user re-opening the modal. Subtasks are pulled on the same cadence so a + // freshly-created card snaps in beneath its announcement. useEffect(() => { if (refreshKey === undefined) return retryTimers.current.forEach(clearTimeout) - // Dense early schedule: with signal-driven outbox dispatch the status comment is written within - // a fraction of a second, so the first probes catch it almost immediately; the later ones cover - // a slower poll-fallback dispatch. Cheap (id-keyed merge, no-op when nothing changed). retryTimers.current = [250, 600, 1100, 1800, 2800, 4200, 5600] - .map((ms) => setTimeout(() => { void mergeLatest() }, ms)) + .map((ms) => setTimeout(() => { void mergeLatest(); void loadSubtasks() }, ms)) return () => { retryTimers.current.forEach(clearTimeout); retryTimers.current = [] } - }, [refreshKey, mergeLatest]) + }, [refreshKey, mergeLatest, loadSubtasks]) - // Gentle live polling while the branch is open — picks up other participants' messages/edits. - // Paused while the viewer is editing so a refresh never clobbers an in-progress draft. + // Gentle live polling while the branch is open — picks up other participants' messages/edits and + // subtask changes. Paused while the viewer is editing so a refresh never clobbers a draft. useEffect(() => { if (editingId || editingGenesis) return - const id = setInterval(() => { void mergeLatest() }, 5000) + const id = setInterval(() => { void mergeLatest(); void loadSubtasks() }, 5000) return () => clearInterval(id) - }, [mergeLatest, editingId, editingGenesis]) + }, [mergeLatest, loadSubtasks, editingId, editingGenesis]) const genesis = comments.find((c) => c.isGenesisComment) ?? null const stream = comments.filter((c) => !c.isGenesisComment) @@ -210,7 +351,7 @@ export function BranchFeed({ const id = requestAnimationFrame(scrollToBottom) return () => cancelAnimationFrame(id) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [comments]) + }, [comments, subtasks]) // ── Pinned Author's Note: show the condensed header once the full card scrolls past the top ── const updateGenesisVisibility = useCallback(() => { @@ -263,6 +404,103 @@ export function BranchFeed({ const showCompleteAction = !!onCompleteTask const showDescription = !!onSaveDescription + // ── Subtask mutations (live inline in the branch; no separate panel) ────────────────────────── + + // Anyone with access can complete/reopen — it applies globally (server-side, status-based). + const toggleSubtaskComplete = async (s: Todo) => { + if (subtaskPendingRef.current.has(s.id)) return + const nextStatus = isSubtaskDone(s) ? "todo" : "done" + setSubtaskBusy(s.id, true) + setSubtasks((prev) => prev.map((it) => it.id === s.id + ? { ...it, status: nextStatus === "done" ? "Done" : "Todo" } : it)) + try { + await updateSubtask(s.id, { status: nextStatus }) + // A "completed a subtask" system event is emitted async; pull it in shortly. + retryTimers.current.push(setTimeout(() => { void mergeLatest() }, 600)) + } catch (e) { + setSubtasks((prev) => prev.map((it) => it.id === s.id ? s : it)) // revert + setError(getApiErrorMessage(e)) + } finally { + setSubtaskBusy(s.id, false) + } + } + + // Take a subtask into work / step back out. This is **per-user**: each viewer joins or leaves + // independently (server-side worker rows), so one person working never flips it "in work" for + // another — everyone just sees an anonymous "N working" count. The viewer's own membership is + // `isWorking`; the count is `workerCount`. + const toggleSubtaskWork = async (s: Todo) => { + if (subtaskPendingRef.current.has(s.id)) return + const amWorking = !!s.isWorking + setSubtaskBusy(s.id, true) + // Optimistic: flip my own membership and nudge the shared count. + setSubtasks((prev) => prev.map((it) => it.id === s.id + ? { ...it, isWorking: !amWorking, workerCount: Math.max(0, (it.workerCount ?? 0) + (amWorking ? -1 : 1)) } + : it)) + try { + if (amWorking) await leaveTodo(s.id) + else await joinTodo(s.id) + // Pull fresh worker counts so other participants' numbers settle quickly. + retryTimers.current.push(setTimeout(() => { void loadSubtasks() }, 500)) + } catch (e) { + setSubtasks((prev) => prev.map((it) => it.id === s.id ? s : it)) // revert + setError(getApiErrorMessage(e)) + } finally { + setSubtaskBusy(s.id, false) + } + } + + // Owner-only: rename a subtask (priority is intentionally not part of the subtask UX). + const saveSubtaskTitle = async (id: string, title: string) => { + const trimmed = title.trim() + if (!trimmed) return + const snapshot = subtasks + setSubtasks((prev) => prev.map((it) => it.id === id ? { ...it, title: trimmed } : it)) + try { + await updateSubtask(id, { title: trimmed }) + } catch (e) { + setSubtasks(snapshot) + setError(getApiErrorMessage(e)) + } + } + + const removeSubtask = async (s: Todo) => { + if (!isOwner || subtaskPendingRef.current.has(s.id)) return + setSubtaskBusy(s.id, true) + const subtasksSnapshot = subtasks + + // Deleting a subtask also clears the system events it left in the branch ("added a subtask: …", + // "completed a subtask: …"). Remove them optimistically and suppress their ids so polling can't + // re-add them before the server-side cascade (SubtaskDeletedIntegrationEvent) completes. + const suffix = `: ${s.title}` + const related = comments.filter( + (c) => c.isSystemComment && /subtask/i.test(c.content) && c.content.trimEnd().endsWith(suffix), + ) + const relatedIds = new Set(related.map((c) => c.id)) + + setSubtasks((prev) => prev.filter((it) => it.id !== s.id)) // optimistic (animates out) + if (relatedIds.size) { + related.forEach((c) => suppressedCommentIds.current.add(c.id)) + setComments((prev) => prev.filter((c) => !relatedIds.has(c.id))) + setTotalCount((n) => Math.max(0, n - relatedIds.size)) + } + + try { + await deleteSubtask(s.id) + } catch (e) { + setSubtasks(subtasksSnapshot) + if (relatedIds.size) { + related.forEach((c) => suppressedCommentIds.current.delete(c.id)) + setComments((prev) => [...prev, ...related].sort((a, b) => + a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0)) + setTotalCount((n) => n + relatedIds.size) + } + setError(getApiErrorMessage(e)) + } finally { + setSubtaskBusy(s.id, false) + } + } + const handleEditSave = async (id: string) => { const content = editContent.trim() if (!content || submitting) return @@ -344,6 +582,20 @@ export function BranchFeed({ return () => document.removeEventListener("mousedown", handle) }, [plusMenuOpen]) + const enterComposeMode = (mode: ComposeMode) => { + setComposeMode(mode) + setNewContent("") + setPlusMenuOpen(false) + if (composeRef.current) composeRef.current.style.height = "auto" + setTimeout(() => composeRef.current?.focus(), 50) + } + + const exitComposeMode = () => { + setComposeMode("text") + setNewContent("") + if (composeRef.current) composeRef.current.style.height = "auto" + } + const handleSubmitWithMode = async () => { const content = newContent.trim() if (!content || submitting) return @@ -358,13 +610,29 @@ export function BranchFeed({ await load(1, true, false) } setComposeMode("text") + setNewContent("") + } else if (composeMode === "subtask") { + // Subtasks are authored exactly like the description: from the same field. No priority. + const created = await createSubtask(todoId, { title: content }) + setSubtasks((prev) => [...prev, created]) + setNewContent("") + // Creating a subtask closes the subtask composer and returns to plain-message mode. + setComposeMode("text") + // The "added a subtask" announcement is emitted async — pull it in so the card anchors + // beneath it. + retryTimers.current.push( + setTimeout(() => { void mergeLatest() }, 350), + setTimeout(() => { void mergeLatest() }, 1000), + setTimeout(() => { void mergeLatest() }, 2000), + ) + setTimeout(scrollToBottom, 60) } else { const c = await addComment(todoId, content) setComments((prev) => [...prev, c]) setTotalCount((n) => n + 1) + setNewContent("") setTimeout(scrollToBottom, 40) } - setNewContent("") if (composeRef.current) composeRef.current.style.height = "auto" } catch (e) { setError(getApiErrorMessage(e)) @@ -373,7 +641,16 @@ export function BranchFeed({ } } - const feed = buildFeed(stream) + const feed = buildFeed(stream, subtasks) + + const composeAccent = composeMode === "text" ? "#0a0a0a" : "#4f46e5" + + // Once the task is completed the "+" menu offers nothing for now — no description, no subtask, + // and no take/complete actions — so the menu simply doesn't open on a done task. + const menuShowsDescription = showDescription && !isCompleted + const menuShowsSubtask = isOwner && !isCompleted + const menuShowsActions = (showWorkAction || showCompleteAction) && !isCompleted + const hasMenuItems = menuShowsDescription || menuShowsSubtask || menuShowsActions return ( <div style={{ display: "flex", flexDirection: "column", gap: 0, height: "100%", minHeight: 0 }}> @@ -612,7 +889,7 @@ export function BranchFeed({ <p style={{ fontSize: 12, color: "#a3a3a3" }}>Loading…</p> )} - {!loading && stream.length === 0 && ( + {!loading && feed.length === 0 && ( <p style={{ fontSize: 12, color: "#a3a3a3", fontStyle: "italic" }}> No messages yet </p> @@ -634,32 +911,51 @@ export function BranchFeed({ pointerEvents: "none", }} /> - {feed.map((item) => { - if (item.type === "separator") { + <AnimatePresence initial={false}> + {feed.map((item) => { + if (item.type === "separator") { + return <DaySeparator key={item.key} label={item.label} /> + } + if (item.type === "subtask") { + const s = item.subtask + return ( + <SubtaskCard + key={`sub-${s.id}`} + subtask={s} + meta={item.meta} + isOwner={isOwner} + done={isSubtaskDone(s)} + workerCount={subtaskWorkerCount(s)} + viewerWorking={!!s.isWorking} + pending={subtaskPending.has(s.id)} + onToggleComplete={() => toggleSubtaskComplete(s)} + onToggleWork={() => toggleSubtaskWork(s)} + onDelete={() => removeSubtask(s)} + onSaveTitle={(title) => saveSubtaskTitle(s.id, title)} + /> + ) + } + const c = item.comment + if (c.isSystemComment) { + return <SystemEvent key={c.id} comment={c} /> + } return ( - <DaySeparator key={item.key} label={item.label} /> + <MessageItem + key={c.id} + comment={c} + isOwner={isOwner} + editingId={editingId} + editContent={editContent} + submitting={submitting} + onEditStart={(id, content) => { setEditingId(id); setEditContent(content) }} + onEditCancel={() => setEditingId(null)} + onEditSave={handleEditSave} + onEditContentChange={setEditContent} + onDelete={handleDelete} + /> ) - } - const c = item.comment - if (c.isSystemComment) { - return <SystemEvent key={c.id} comment={c} /> - } - return ( - <MessageItem - key={c.id} - comment={c} - isOwner={isOwner} - editingId={editingId} - editContent={editContent} - submitting={submitting} - onEditStart={(id, content) => { setEditingId(id); setEditContent(content) }} - onEditCancel={() => setEditingId(null)} - onEditSave={handleEditSave} - onEditContentChange={setEditContent} - onDelete={handleDelete} - /> - ) - })} + })} + </AnimatePresence> </div> )} </div> @@ -672,8 +968,8 @@ export function BranchFeed({ {/* ── Compose ── */} <div style={{ position: "relative", marginTop: 8 }}> - {/* Mode chip — slides in above compose box when in description mode */} - {composeMode === "description" && ( + {/* Mode chip — slides in above compose box when authoring the description or a subtask */} + {composeMode !== "text" && ( <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6, @@ -684,15 +980,17 @@ export function BranchFeed({ background: "#eef2ff", border: "1px solid #c7d2fe", borderRadius: 8, padding: "4px 8px 4px 7px", }}> - <FileText size={11} color="#4f46e5" strokeWidth={2.2} /> + {composeMode === "description" + ? <FileText size={11} color="#4f46e5" strokeWidth={2.2} /> + : <ListTree size={11} color="#4f46e5" strokeWidth={2.2} />} <span style={{ fontSize: 11, fontWeight: 900, letterSpacing: "0.06em", textTransform: "uppercase", color: "#4f46e5", }}> - Description + {composeMode === "description" ? "Description" : "Subtask"} </span> <button - onClick={() => { setComposeMode("text"); setNewContent("") }} + onClick={exitComposeMode} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 16, height: 16, borderRadius: 4, border: "none", @@ -707,7 +1005,7 @@ export function BranchFeed({ </button> </div> <span style={{ fontSize: 10.5, fontWeight: 600, color: "#a3a3a3" }}> - task description · ⌘+↵ to send + {composeMode === "description" ? "task description · ⌘+↵ to send" : "a step in this task · ↵ to add"} </span> </div> )} @@ -715,8 +1013,8 @@ export function BranchFeed({ {/* Compose box — position:relative anchors the floating menu */} <div style={{ position: "relative", - background: composeMode === "description" ? "#faf5ff" : "#fafafa", - border: composeMode === "description" ? "1.5px solid #c7d2fe" : "1px solid #f0f0f0", + background: composeMode !== "text" ? "#faf5ff" : "#fafafa", + border: composeMode !== "text" ? "1.5px solid #c7d2fe" : "1px solid #f0f0f0", borderRadius: 14, padding: 4, display: "flex", @@ -725,8 +1023,8 @@ export function BranchFeed({ transition: "border-color 200ms, background 200ms", }}> - {/* Attach menu — absolutely above the compose box */} - {plusMenuOpen && ( + {/* Attach menu — absolutely above the compose box (empty/closed on a completed task) */} + {plusMenuOpen && hasMenuItems && ( <div ref={plusMenuRef} style={{ @@ -744,16 +1042,11 @@ export function BranchFeed({ }} > {/* Author-only: add the task description (disabled once one exists) */} - {showDescription && ( + {menuShowsDescription && ( <> <MenuSectionLabel>Attach</MenuSectionLabel> <button - onClick={() => { - if (genesis) return - setComposeMode("description") - setPlusMenuOpen(false) - setTimeout(() => composeRef.current?.focus(), 50) - }} + onClick={() => { if (!genesis) enterComposeMode("description") }} disabled={!!genesis} style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, @@ -799,10 +1092,42 @@ export function BranchFeed({ </> )} - {/* Task actions — available to everyone (owner & collaborators) */} - {(showWorkAction || showCompleteAction) && ( + {/* Author-only: add a subtask — authored in the same field, appears inline in the branch */} + {menuShowsSubtask && ( + <> + {!menuShowsDescription && <MenuSectionLabel>Attach</MenuSectionLabel>} + <button + onClick={() => enterComposeMode("subtask")} + style={{ + width: "100%", display: "flex", alignItems: "center", gap: 10, + padding: "8px 10px", borderRadius: 10, border: "none", cursor: "pointer", + background: "transparent", textAlign: "left", transition: "background 100ms", + }} + onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "#f5f5f5" }} + onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent" }} + > + <div style={{ + width: 28, height: 28, borderRadius: 8, flexShrink: 0, + background: "#eef2ff", display: "flex", alignItems: "center", justifyContent: "center", + }}> + <ListTree size={14} color="#4f46e5" strokeWidth={1.9} /> + </div> + <div> + <div style={{ fontSize: 12.5, fontWeight: 800, color: "#0a0a0a", letterSpacing: "-0.01em" }}> + Subtask + </div> + <div style={{ fontSize: 10.5, fontWeight: 500, color: "#a3a3a3", marginTop: 1 }}> + Add a step to this task + </div> + </div> + </button> + </> + )} + + {/* Task actions — available to everyone (owner & collaborators), hidden once done */} + {menuShowsActions && ( <> - {showDescription && ( + {menuShowsDescription && ( <div style={{ height: 1, background: "#f3f3f3", margin: "6px 8px" }} /> )} <MenuSectionLabel>Actions</MenuSectionLabel> @@ -884,22 +1209,32 @@ export function BranchFeed({ autoResize(e.target) }} onKeyDown={(e) => { - if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + // Subtask titles are single-line — plain Enter adds the step. Description/messages + // use ⌘/Ctrl+Enter so multi-line text can be composed freely. + if (e.key === "Enter" && composeMode === "subtask" && !e.shiftKey) { + e.preventDefault() + handleSubmitWithMode() + } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault() handleSubmitWithMode() } - if (e.key === "Escape" && composeMode === "description") { - setComposeMode("text") - setNewContent("") + if (e.key === "Escape" && composeMode !== "text") { + exitComposeMode() } }} rows={1} placeholder={ composeMode === "description" ? "Enter task description…" - : "Write in branch… ⌘+↵ to send" + : composeMode === "subtask" + ? "Add a subtask…" + : "Write in branch… ⌘+↵ to send" + } + maxLength={ + composeMode === "description" ? GENESIS_MAX + : composeMode === "subtask" ? SUBTASK_MAX + : COMMENT_MAX } - maxLength={composeMode === "description" ? GENESIS_MAX : COMMENT_MAX} disabled={submitting} style={{ flex: 1, background: "transparent", border: "none", outline: "none", @@ -915,14 +1250,14 @@ export function BranchFeed({ width: 32, height: 32, borderRadius: 10, border: "none", display: "flex", alignItems: "center", justifyContent: "center", cursor: newContent.trim() && !submitting ? "pointer" : "default", - background: newContent.trim() && !submitting - ? (composeMode === "description" ? "#4f46e5" : "#0a0a0a") - : "#e5e5e5", + background: newContent.trim() && !submitting ? composeAccent : "#e5e5e5", flexShrink: 0, transition: "background 120ms", }} > - <Send size={14} color={newContent.trim() && !submitting ? "white" : "#a3a3a3"} /> + {submitting && composeMode === "subtask" + ? <Loader2 size={14} color="white" className="animate-spin" /> + : <Send size={14} color={newContent.trim() && !submitting ? "white" : "#a3a3a3"} />} </button> </div> </div> @@ -1065,6 +1400,398 @@ function SystemEvent({ comment }: { comment: TodoComment }) { ) } +/* ── Subtask cluster ── one integrated thread on the rail: a minimal "added a subtask" caption, + the card itself (with an all-viewers "In progress" indicator), and — when done — a compact + completion "reply" the rail gently bends down to. The card's completion toggle is the primary + rail marker; the green completion node sits just below it. The subtask's create/complete system + comments are folded in here (parsed into `meta`) instead of appearing as separate rail nodes. */ +const SUBTASK_TOGGLE = 26 +const SUBTASK_DELETE_ZONE = 50 +// x of the rail centre within a cluster's content box (content starts RAIL_GUTTER from the wrapper). +const RAIL_X = RAIL_CENTER - RAIL_GUTTER +// A subtask branches off the main rail into its own little sub-branch: the card + its completion +// reply sit offset to the side (SUBTASK_OFFSET), joined back to the rail by connectors. The ONLY +// marker is the subtask's completion toggle, which lives on the sub-branch (SUB_TOGGLE_X) — the +// completion reply is just another (icon-less) reply on that sub-branch. +const SUBTASK_OFFSET = 28 // card / reply content left edge (the sub-branch column) +const SUB_TOGGLE_X = 4 // the toggle's centre x — the subtask's sole marker, on the sub-branch +interface SubtaskCardProps { + subtask: Todo + meta: SubtaskMeta + isOwner: boolean + done: boolean + /** How many people are working on this subtask (per-user; shown anonymously to everyone). */ + workerCount: number + /** Whether THIS viewer is one of them (drives their own take-into-work / step-out toggle). */ + viewerWorking: boolean + pending: boolean + onToggleComplete: () => void + onToggleWork: () => void + onDelete: () => void + onSaveTitle: (title: string) => void +} + +function SubtaskCard({ + subtask, meta, isOwner, done, workerCount, viewerWorking, pending, + onToggleComplete, onToggleWork, onDelete, onSaveTitle, +}: SubtaskCardProps) { + const [hovered, setHovered] = useState(false) + const [deleteHovered, setDeleteHovered] = useState(false) + const [editing, setEditing] = useState(false) + const [editTitle, setEditTitle] = useState(subtask.title) + const editInputRef = useRef<HTMLTextAreaElement>(null) + + const beginEdit = () => { + if (!isOwner) return + setEditTitle(subtask.title) + setEditing(true) + setTimeout(() => { + const el = editInputRef.current + if (!el) return + el.focus(); el.select() + el.style.height = "auto" + el.style.height = el.scrollHeight + "px" + }, 40) + } + const commitEdit = () => { + const t = editTitle.trim() + if (t && t !== subtask.title) onSaveTitle(t) + setEditing(false) + } + + // Owner gets the slide-out delete affordance, so reserve the strip's width on the right. + const bodyPaddingRight = isOwner && !editing ? SUBTASK_DELETE_ZONE - 2 : 12 + + const completedName = meta.completedBy?.trim() + const completedAt = meta.completedAt + // Someone (anyone) is working on it → amber accents; the viewer's own membership drives the toggle. + const someoneWorking = workerCount > 0 + // Sub-branch accent — the little branch the subtask hangs from, tinted to its state. + const branchColor = done ? "#a7f3d0" : someoneWorking ? "#fcd98c" : "#e1e1e6" + + return ( + <motion.div + layout + initial={{ opacity: 0, y: 8, scale: 0.98 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, scale: 0.96, height: 0, marginTop: -2, marginBottom: 0 }} + transition={SPRING_SNAP} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => { setHovered(false); setDeleteHovered(false) }} + style={{ position: "relative", padding: "6px 0" }} + > + {/* ── Card row ── the subtask forks off the main rail into its own sub-branch. The completion + toggle is the subtask's ONLY marker, sitting on the sub-branch at the card's VERTICAL + CENTRE; a fork connector reaches in from the main rail, and (when done) the sub-branch + continues downward to the completion reply. */} + <div style={{ position: "relative", paddingLeft: SUBTASK_OFFSET }}> + {/* Fork — main rail → the sub-branch toggle */} + <span style={{ + position: "absolute", left: RAIL_X, top: "50%", transform: "translateY(-50%)", + width: SUB_TOGGLE_X - RAIL_X, height: 2, borderRadius: 1, + background: branchColor, transition: "background 200ms", + }} /> + {/* Stem — toggle → the offset card */} + <span style={{ + position: "absolute", left: SUB_TOGGLE_X, top: "50%", transform: "translateY(-50%)", + width: SUBTASK_OFFSET - SUB_TOGGLE_X, height: 2, borderRadius: 1, + background: branchColor, transition: "background 200ms", + }} /> + {/* Sub-branch continuation downward to the completion reply (only when done) */} + {done && ( + <span style={{ + position: "absolute", left: SUB_TOGGLE_X - 1, top: "50%", bottom: -6, width: 2, + background: branchColor, transition: "background 200ms", + }} /> + )} + {/* Completion toggle — the subtask's sole marker, on the sub-branch, vertically centred */} + <button + onClick={onToggleComplete} + disabled={pending} + aria-label={done ? "Mark subtask not done" : "Complete subtask"} + style={{ + position: "absolute", + left: SUB_TOGGLE_X - SUBTASK_TOGGLE / 2, + top: "50%", + width: SUBTASK_TOGGLE, height: SUBTASK_TOGGLE, borderRadius: "50%", + border: done ? "none" : `2px solid ${someoneWorking ? "#f59e0b" : "#d4d4d4"}`, + background: done ? "#10b981" : "#ffffff", + boxShadow: done + ? "0 0 0 3px #ffffff, 0 2px 6px -1px rgba(16,185,129,0.5)" + : "0 0 0 3px #ffffff", + display: "flex", alignItems: "center", justifyContent: "center", + cursor: pending ? "default" : "pointer", padding: 0, zIndex: 3, + transition: "background 160ms, border-color 160ms, transform 120ms, box-shadow 160ms", + transform: `translateY(-50%) scale(${hovered && !done ? 1.12 : 1})`, + }} + > + <AnimatePresence mode="wait" initial={false}> + {done ? ( + <motion.span key="done" initial={{ scale: 0, rotate: -30 }} animate={{ scale: 1, rotate: 0 }} exit={{ scale: 0 }} transition={{ type: "spring", stiffness: 500, damping: 18 }}> + <Check size={15} color="white" strokeWidth={3} /> + </motion.span> + ) : someoneWorking ? ( + <motion.span key="work" initial={{ scale: 0 }} animate={{ scale: 1 }} style={{ display: "flex" }}> + <span style={{ width: 8, height: 8, borderRadius: "50%", background: "#f59e0b" }} className="animate-pulse" /> + </motion.span> + ) : hovered ? ( + <motion.span key="hover" initial={{ scale: 0 }} animate={{ scale: 1 }}> + <Check size={14} color="#10b981" strokeWidth={3} /> + </motion.span> + ) : null} + </AnimatePresence> + </button> + + {/* Card body — taller, task-like; overflow:hidden clips the delete strip + rounded corners. + alignItems:flex-start lets the card grow downward when a long title wraps. */} + <div style={{ + position: "relative", overflow: "hidden", + display: "flex", alignItems: "flex-start", gap: 10, + padding: "11px 12px", paddingRight: bodyPaddingRight, + borderRadius: 12, + background: done ? "#f7fdfb" : someoneWorking ? "#fffdf5" : "#fafafa", + border: `1px solid ${done ? "#d7f5ea" : someoneWorking && !done ? "#fde68a" : "#f0f0f0"}`, + transition: "background 200ms, border-color 200ms, padding 160ms", + }}> + {/* Title (wraps freely) or inline editor */} + {editing ? ( + <textarea + ref={editInputRef} + value={editTitle} + onChange={(e) => { + setEditTitle(e.target.value) + e.target.style.height = "auto" + e.target.style.height = e.target.scrollHeight + "px" + }} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); commitEdit() } + if (e.key === "Escape") setEditing(false) + }} + onBlur={commitEdit} + maxLength={SUBTASK_MAX} + rows={1} + style={{ + flex: 1, minWidth: 0, background: "white", + border: "1.5px solid #c7d2fe", borderRadius: 8, outline: "none", + padding: "7px 10px", fontSize: 13.5, fontWeight: 400, lineHeight: 1.4, + color: "#262626", fontFamily: "inherit", resize: "none", + maxHeight: 160, overflowY: "auto", + }} + /> + ) : ( + <span + onDoubleClick={beginEdit} + title={isOwner ? "Double-click to edit" : undefined} + style={{ + flex: 1, minWidth: 0, + // Non-bold so it reads as a plain branch step; wraps so a long step grows the card. + fontSize: 13.5, fontWeight: 400, lineHeight: 1.45, paddingTop: 4, + color: done ? "#a3a3a3" : "#262626", + textDecoration: done ? "line-through" : "none", + overflowWrap: "anywhere", wordBreak: "break-word", + cursor: isOwner ? "text" : "default", + transition: "color 200ms", + }} + > + {subtask.title} + </span> + )} + + {/* In-work presence badge — per-user, shown to EVERY viewer as an anonymous count + ("N working"). It never names anyone. When the viewer is one of the workers the badge + reads "You + N" so they can tell their own state at a glance. */} + <AnimatePresence initial={false}> + {someoneWorking && !done && !editing && ( + <motion.span + initial={{ opacity: 0, scale: 0.7 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.7 }} + transition={{ type: "spring", stiffness: 480, damping: 26 }} + title={`${workerCount} ${workerCount === 1 ? "person is" : "people are"} working on this`} + style={{ + display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0, marginTop: 2, + fontSize: 9.5, fontWeight: 900, letterSpacing: "0.06em", textTransform: "uppercase", + color: "#b45309", + background: "linear-gradient(180deg,#fff8e6,#fef0c7)", + border: "1px solid #fce4a6", + padding: "3px 9px 3px 7px", borderRadius: 999, + boxShadow: "0 1px 3px -1px rgba(245,158,11,0.35)", + }} + > + <span style={{ position: "relative", width: 7, height: 7, flexShrink: 0 }}> + <span className="animate-ping" style={{ position: "absolute", inset: 0, borderRadius: "50%", background: "#f59e0b", opacity: 0.6 }} /> + <span style={{ position: "absolute", inset: 1, borderRadius: "50%", background: "#f59e0b" }} /> + </span> + {viewerWorking + ? (workerCount > 1 ? `You + ${workerCount - 1} working` : "You're working") + : `${workerCount} working`} + </motion.span> + )} + </AnimatePresence> + + {/* Inline actions — revealed on hover, hidden under the delete panel. "Take into work" is + available to EVERYONE (global, like completion); editing stays owner-only. */} + {!editing && (isOwner || !done) && ( + <div style={{ + display: "flex", alignItems: "center", gap: 2, flexShrink: 0, marginTop: 1, + opacity: hovered && !deleteHovered ? 1 : 0, transition: "opacity 140ms", + pointerEvents: hovered && !deleteHovered ? "auto" : "none", + }}> + {isOwner && ( + <SubtaskIconButton label="Edit subtask" title="Edit" color="#525252" hoverBg="#f0f0f0" onClick={beginEdit} disabled={pending}> + <Pencil size={13} strokeWidth={2} /> + </SubtaskIconButton> + )} + {!done && ( + <SubtaskIconButton + label={viewerWorking ? "Stop working on subtask" : "Take subtask into work"} + title={viewerWorking ? "Step out" : "Take into work"} + color={viewerWorking ? "#b45309" : "#6366f1"} hoverBg="#f0f0f0" + onClick={onToggleWork} disabled={pending} + > + {viewerWorking ? <Pause size={14} strokeWidth={2} /> : <Zap size={14} strokeWidth={2} />} + </SubtaskIconButton> + )} + </div> + )} + + {/* Delete strip — slides in from the right, identical in feel to a task card's delete panel */} + {isOwner && !editing && ( + <div + onMouseEnter={() => setDeleteHovered(true)} + onMouseLeave={() => setDeleteHovered(false)} + style={{ + position: "absolute", top: 0, right: 0, bottom: 0, + width: SUBTASK_DELETE_ZONE, zIndex: 2, + display: "flex", alignItems: "center", justifyContent: "center", + overflow: "hidden", cursor: pending ? "default" : "pointer", + }} + > + <AnimatePresence> + {deleteHovered && ( + <motion.button + key="sub-delete-panel" + type="button" + onClick={(e) => { e.stopPropagation(); if (!pending) onDelete() }} + disabled={pending} + aria-label="Delete subtask" + variants={{ + hidden: { clipPath: "inset(0 0 0 100%)", transition: { duration: 0.18, ease: [0.4, 0, 1, 1] } }, + visible: { clipPath: "inset(0 0 0 0%)", transition: { duration: 0.32, ease: [0.16, 1, 0.3, 1] } }, + }} + initial="hidden" + animate="visible" + exit="hidden" + whileHover={{ filter: "brightness(1.1)" }} + style={{ + position: "absolute", inset: 0, border: "none", padding: 0, + display: "flex", alignItems: "center", justifyContent: "center", + color: "white", cursor: pending ? "default" : "pointer", + background: "linear-gradient(to right, rgba(239,68,68,0) 0%, rgba(239,68,68,0.85) 38%, #dc2626 100%)", + boxShadow: "-6px 0 18px rgba(239,68,68,0.18)", + }} + > + <motion.div + variants={{ + hidden: { scale: 0.5, opacity: 0, y: 6 }, + visible: { scale: 1, opacity: 1, y: 0, transition: { delay: 0.06, type: "spring", stiffness: 420, damping: 22 } }, + }} + style={{ display: "flex" }} + > + {pending ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} strokeWidth={2.2} />} + </motion.div> + </motion.button> + )} + </AnimatePresence> + </div> + )} + </div> + </div> + + {/* ── Completion reply ── its own green completion node on the rail (vertically centred) with + an offset note; shown whenever the subtask is done. The name fills in once the folded + system comment lands, otherwise a nameless "Completed" shows immediately. */} + <AnimatePresence initial={false}> + {done && ( + <SubtaskCompletionReply + key="completion" + name={completedName} + at={completedAt} + /> + )} + </AnimatePresence> + </motion.div> + ) +} + +/* ── Completion reply ── another reply hanging off the subtask's sub-branch, with NO rail icon. + A soft "└" elbow continues the sub-branch from the card down into an icon-less note. */ +const REPLY_ROW = 26 +function SubtaskCompletionReply({ name, at }: { name?: string; at?: string }) { + return ( + <motion.div + initial={{ opacity: 0, y: -6 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -4 }} + transition={SPRING_SNAP} + style={{ position: "relative", paddingLeft: SUBTASK_OFFSET, minHeight: REPLY_ROW }} + > + {/* "└" elbow — continues the sub-branch down from the card, then curves into the note. + No node/icon: the completion is just a reply on the sub-branch. */} + <span style={{ + position: "absolute", left: SUB_TOGGLE_X - 1, top: -8, + width: SUBTASK_OFFSET - SUB_TOGGLE_X, height: REPLY_ROW / 2 + 8, + borderLeft: "2px solid #a7f3d0", borderBottom: "2px solid #a7f3d0", + borderBottomLeftRadius: 12, pointerEvents: "none", + }} /> + + {/* Note (icon-less) */} + <div style={{ display: "flex", alignItems: "center", gap: 7, minHeight: REPLY_ROW }}> + <span style={{ fontSize: 12, fontWeight: 500, color: "#6f7d76", lineHeight: 1.3 }}> + {name + ? <><strong style={{ color: "#0a0a0a", fontWeight: 800 }}>{name}</strong> completed sub task</> + : <strong style={{ color: "#059669", fontWeight: 800 }}>Sub task completed</strong>} + </span> + {at && ( + <span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: "0.04em", color: "#bdbdbd" }}> + {formatTimeHHMM(at)} + </span> + )} + </div> + </motion.div> + ) +} + +interface SubtaskIconButtonProps { + label: string + title: string + color: string + hoverBg: string + disabled?: boolean + onClick: () => void + children: ReactNode +} +function SubtaskIconButton({ label, title, color, hoverBg, disabled, onClick, children }: SubtaskIconButtonProps) { + return ( + <button + onClick={onClick} + disabled={disabled} + aria-label={label} + title={title} + style={{ + width: 26, height: 26, borderRadius: 8, border: "none", + display: "flex", alignItems: "center", justifyContent: "center", + background: "transparent", cursor: disabled ? "default" : "pointer", color, + transition: "background 120ms", + }} + onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = hoverBg }} + onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent" }} + > + {children} + </button> + ) +} + /* ── Message item ── */ interface MessageItemProps { comment: TodoComment diff --git a/frontend/src/components/todos/edit-todo-modal/modal.tsx b/frontend/src/components/todos/edit-todo-modal/modal.tsx index 987ca5f9..122622ee 100644 --- a/frontend/src/components/todos/edit-todo-modal/modal.tsx +++ b/frontend/src/components/todos/edit-todo-modal/modal.tsx @@ -1,9 +1,10 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { motion } from "framer-motion" import { X } from "lucide-react" import { ModalPortal } from "@/components/ui/modal-portal" +import { useAutosave } from "@/hooks/use-autosave" import { useAuthStore } from "@/store/auth" import { useFriends } from "@/hooks/use-friends" import { SPRING_STANDARD } from "@/lib/animations" @@ -18,6 +19,48 @@ import { type OpenPopover = "priority" | "date" | "category" | "visibility" | null +/** + * Equality for the owner autosave channel that deliberately ignores `description`. + * The description has a dedicated editor (the branch's "Author's Note") that persists + * it directly, so changes to it must not trigger a second write from this channel. + */ +function samePayloadExceptDescription(a: UpdateTodoPayload, b: UpdateTodoPayload): boolean { + return ( + a.title === b.title && + a.priority === b.priority && + a.dueDate === b.dueDate && + a.categoryId === b.categoryId && + a.isPublic === b.isPublic && + a.requiredWorkers === b.requiredWorkers && + a.clearRequiredWorkers === b.clearRequiredWorkers && + JSON.stringify(a.sharedWithUserIds ?? []) === JSON.stringify(b.sharedWithUserIds ?? []) + ) +} + +/** + * Build the owner payload that the modal's local state is initialised from, straight + * from a task. Used as the autosave baseline so a freshly-opened task is never seen as + * "dirty". Mirrors the field initialisation and `buildOwnerPayload` normalisation. + */ +function todoToOwnerPayload(todo: Todo): UpdateTodoPayload { + const visFriends = todo.isPublic || (todo.sharedWithUserIds?.length ?? 0) > 0 + const shared = todo.isPublic ? [] : (todo.sharedWithUserIds ?? []) + const dueDate = todo.dueDate + ? new Date(new Date(todo.dueDate).toISOString().split("T")[0]).toISOString() + : null + return { + title: todo.title.trim(), + description: (todo.description ?? "").trim() || null, + priority: getPriorityNumber(getPriorityString(todo.priority)), + dueDate, + categoryId: todo.categoryId || null, + isPublic: false, + sharedWithUserIds: visFriends ? shared : [], + requiredWorkers: visFriends ? 1 + shared.length : null, + clearRequiredWorkers: !visFriends, + } +} + export interface EditTodoModalProps { todo: Todo categories: Category[] @@ -68,7 +111,6 @@ export function EditTodoModal({ const [categoryId, setCategoryId] = useState<string | null>(todo.categoryId ?? null) const [openPopover, setOpenPopover] = useState<OpenPopover>(null) const [editingTitle, setEditingTitle] = useState(false) - const [saving, setSaving] = useState(false) const initialVis = (todo.isPublic || (todo.sharedWithUserIds?.length ?? 0) > 0) ? "friends" as const @@ -140,10 +182,14 @@ export function EditTodoModal({ setEditingTitle(false) } - // ── Save ────────────────────────────────────────────────────────────────── + // ── Autosave ──────────────────────────────────────────────────────────────── + // No Save button: every committed field change is persisted automatically. The + // owner channel persists the full task payload; a shared viewer persists only their + // private category preference. Both are debounced and single-flight (see useAutosave). + // Full owner payload. `descOverride` lets the branch persist a description edit // without disturbing the other fields (single source of truth = the task). - const buildOwnerPayload = (descOverride?: string | null): UpdateTodoPayload => ({ + const buildOwnerPayload = useCallback((descOverride?: string | null): UpdateTodoPayload => ({ title: title.trim(), description: descOverride !== undefined ? descOverride : (description.trim() || null), priority: getPriorityNumber(priority), @@ -153,22 +199,35 @@ export function EditTodoModal({ sharedWithUserIds: visMode === "private" ? [] : sharedIds, requiredWorkers: visMode === "private" ? null : 1 + sharedIds.length, clearRequiredWorkers: visMode === "private", + }), [title, description, priority, dueDate, categoryId, visMode, sharedIds]) + + const ownerPayload = useMemo(() => buildOwnerPayload(), [buildOwnerPayload]) + + const ownerAutosave = useAutosave<UpdateTodoPayload>({ + value: ownerPayload, + enabled: isOwner, + onSave, + // Title is the only required field; an empty title is never persisted (and the + // inline editor already reverts blank titles, so this is a belt-and-braces guard). + validate: (p) => (p.title ?? "").trim().length > 0, + // The description is owned by the branch's "Author's Note" editor, which persists + // it on its own. Excluding it here prevents a duplicate write when a note is saved. + isEqual: samePayloadExceptDescription, }) - const handleSave = async () => { - if (isOwner && !title.trim()) return - setSaving(true) - try { - if (isOwner) { - await onSave(buildOwnerPayload()) - } else { - await onSaveViewerPreference({ viewerCategoryId: categoryId || null }) - } - onClose() - } finally { - setSaving(false) - } - } + const viewerAutosave = useAutosave<string | null>({ + value: categoryId || null, + enabled: !isOwner && canManageViewerCategory, + onSave: (viewerCategoryId) => onSaveViewerPreference({ viewerCategoryId }), + }) + + // Re-anchor both baselines whenever the edited task changes, so switching tasks + // never persists the old task's values against the new one. + useEffect(() => { + ownerAutosave.reset(todoToOwnerPayload(todo)) + viewerAutosave.reset(todo.categoryId ?? null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [todo.id]) // Persist a description edit from the branch's "Author's Note" immediately to the // task (the single source of truth) — no modal close, other fields untouched. @@ -421,55 +480,6 @@ export function EditTodoModal({ /> </div> - {/* Divider */} - <div style={{ height: 1, background: "#f0f0f0" }} /> - - {/* ── (5) Footer ── */} - <div style={{ - padding: "14px 24px", - display: "flex", - alignItems: "center", - gap: 8, - background: "white", - borderRadius: "0 0 28px 28px", - }}> - <button - onClick={onClose} - style={{ - background: "transparent", border: "none", cursor: "pointer", - padding: "12px 18px", borderRadius: 14, - fontSize: 12, fontWeight: 900, letterSpacing: "0.04em", - textTransform: "uppercase", color: "#525252", - transition: "background 120ms", - }} - onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "#f5f5f5" }} - onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "transparent" }} - > - Cancel - </button> - - <div style={{ flex: 1 }} /> - - <button - onClick={handleSave} - disabled={saving || (isOwner ? !title.trim() : !canManageViewerCategory)} - style={{ - padding: "12px 22px", borderRadius: 14, border: "none", cursor: "pointer", - background: (saving || (isOwner && !title.trim()) || (!isOwner && !canManageViewerCategory)) - ? "#e5e5e5" - : "#0a0a0a", - color: (saving || (isOwner && !title.trim()) || (!isOwner && !canManageViewerCategory)) - ? "#a3a3a3" - : "white", - fontSize: 12, fontWeight: 900, letterSpacing: "0.04em", - textTransform: "uppercase", - boxShadow: saving ? "none" : "0 4px 14px rgba(0,0,0,0.18)", - transition: "background 120ms, box-shadow 120ms", - }} - > - {saving ? "Saving…" : "Save"} - </button> - </div> </motion.div> </div> </ModalPortal> diff --git a/frontend/src/components/ui/autosave-indicator.tsx b/frontend/src/components/ui/autosave-indicator.tsx new file mode 100644 index 00000000..86527b25 --- /dev/null +++ b/frontend/src/components/ui/autosave-indicator.tsx @@ -0,0 +1,74 @@ +"use client" + +import { AnimatePresence, motion } from "framer-motion" +import { Check, Loader2, RotateCw } from "lucide-react" +import type { AutosaveStatus } from "@/hooks/use-autosave" +import { cn } from "@/lib/utils" + +interface AutosaveIndicatorProps { + status: AutosaveStatus + /** Optional copy shown in the resting state (default: "Changes save automatically"). */ + idleLabel?: string + className?: string +} + +const LABELS: Record<AutosaveStatus, string> = { + idle: "Changes save automatically", + saving: "Saving…", + saved: "All changes saved", + error: "Couldn’t save — will retry", +} + +/** + * Quiet, non-blocking confirmation that an autosaving form is persisting edits. + * Replaces the explicit Save/Cancel buttons: the user never commits manually, so this + * is the only signal that their change reached the server. + */ +export function AutosaveIndicator({ status, idleLabel, className }: AutosaveIndicatorProps) { + const label = status === "idle" && idleLabel ? idleLabel : LABELS[status] + + return ( + <div + role="status" + aria-live="polite" + className={cn( + "flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider", + status === "error" ? "text-red-500" : status === "saved" ? "text-emerald-600" : "text-gray-400", + className, + )} + > + <span className="flex h-3.5 w-3.5 items-center justify-center" aria-hidden> + <AnimatePresence mode="wait" initial={false}> + {status === "saving" ? ( + <motion.span key="saving" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}> + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + </motion.span> + ) : status === "saved" ? ( + <motion.span + key="saved" + initial={{ scale: 0.5, opacity: 0 }} + animate={{ scale: 1, opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ type: "spring", stiffness: 460, damping: 24 }} + > + <Check className="h-3.5 w-3.5" /> + </motion.span> + ) : status === "error" ? ( + <motion.span key="error" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}> + <RotateCw className="h-3.5 w-3.5" /> + </motion.span> + ) : ( + <motion.span + key="idle" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + className="h-1.5 w-1.5 rounded-full bg-gray-300" + /> + )} + </AnimatePresence> + </span> + <span>{label}</span> + </div> + ) +} diff --git a/frontend/src/hooks/use-autosave.ts b/frontend/src/hooks/use-autosave.ts new file mode 100644 index 00000000..7736ad68 --- /dev/null +++ b/frontend/src/hooks/use-autosave.ts @@ -0,0 +1,158 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" + +/** + * Lifecycle of a single autosave channel. + * + * - `idle` — nothing pending, no change since the last persisted baseline. + * - `saving` — a persist request is in flight (or about to be retried). + * - `saved` — the latest value has been successfully persisted. + * - `error` — the last attempt failed; the next change retries automatically. + */ +export type AutosaveStatus = "idle" | "saving" | "saved" | "error" + +export interface UseAutosaveOptions<T> { + /** The live value to persist. Compared against the last-saved baseline on every change. */ + value: T + /** Persist routine. Must reject (throw) on failure so the hook can surface `error`. */ + onSave: (value: T) => Promise<void> + /** When false the hook is dormant: it never schedules or fires a save. */ + enabled?: boolean + /** Debounce window in ms — coalesces bursts (typing, color-picker drags). Default 600. */ + delay?: number + /** Equality test between two values. Default: identity then a structural JSON compare. */ + isEqual?: (a: T, b: T) => boolean + /** Guard run against the pending value; returning false skips the save (e.g. empty name). */ + validate?: (value: T) => boolean +} + +export interface UseAutosaveResult<T> { + status: AutosaveStatus + /** Persist any pending change immediately (cancels the debounce). Safe to call on close. */ + flush: () => Promise<void> + /** Re-anchor the saved baseline without persisting — call when the edited entity changes. */ + reset: (value: T) => void +} + +const defaultIsEqual = <T,>(a: T, b: T): boolean => + Object.is(a, b) || JSON.stringify(a) === JSON.stringify(b) + +/** + * Debounced, single-flight autosave for modal forms. + * + * Design guarantees: + * - **No lost edits** — a change made while a save is in flight re-fires once that + * save settles; a pending debounce is flushed on unmount and on explicit `flush()`. + * - **No redundant writes** — every persist is gated by an equality check against the + * last successfully saved snapshot, so reverting a field or re-flushing is a no-op. + * - **No overlap** — at most one request is in flight at a time. + * - **Safe after unmount** — status updates are suppressed once unmounted; the final + * flush is fire-and-forget and idempotent. + */ +export function useAutosave<T>({ + value, + onSave, + enabled = true, + delay = 600, + isEqual = defaultIsEqual, + validate, +}: UseAutosaveOptions<T>): UseAutosaveResult<T> { + const [status, setStatus] = useState<AutosaveStatus>("idle") + + // Everything the async machinery reads lives in refs so `run` keeps a stable identity + // and always observes the freshest value/callbacks (never a stale closure). + const valueRef = useRef(value) + const savedRef = useRef(value) // last successfully persisted snapshot (the baseline) + const onSaveRef = useRef(onSave) + const isEqualRef = useRef(isEqual) + const validateRef = useRef(validate) + const enabledRef = useRef(enabled) + const inFlightRef = useRef(false) + const mountedRef = useRef(true) + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) + + valueRef.current = value + onSaveRef.current = onSave + isEqualRef.current = isEqual + validateRef.current = validate + enabledRef.current = enabled + + const setStatusSafe = useCallback((next: AutosaveStatus) => { + if (mountedRef.current) setStatus(next) + }, []) + + // Persist the latest value. If the value moved on mid-flight, persist again once the + // in-flight request settles — so the final state always reaches the server. + const run = useCallback(async (): Promise<void> => { + if (!enabledRef.current) return + if (inFlightRef.current) return // a completion will re-check the latest value + const snapshot = valueRef.current + if (isEqualRef.current(snapshot, savedRef.current)) return + if (validateRef.current && !validateRef.current(snapshot)) return + + inFlightRef.current = true + setStatusSafe("saving") + try { + await onSaveRef.current(snapshot) + savedRef.current = snapshot + inFlightRef.current = false + if (isEqualRef.current(valueRef.current, savedRef.current)) { + setStatusSafe("saved") + } else { + await run() // newer edit arrived while saving — persist it too + } + } catch { + inFlightRef.current = false + setStatusSafe("error") + } + }, [setStatusSafe]) + + // Debounced trigger: re-armed on every value change, cleared if the value is already saved. + useEffect(() => { + if (!enabled) return + if (isEqualRef.current(value, savedRef.current)) return + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + timerRef.current = null + void run() + }, delay) + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [value, enabled, delay, run]) + + const flush = useCallback(async () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + await run() + }, [run]) + + const reset = useCallback( + (next: T) => { + savedRef.current = next + valueRef.current = next + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + setStatusSafe("idle") + }, + [setStatusSafe], + ) + + // On unmount, flush a pending edit so nothing is lost when the modal closes. The + // equality guard inside `run` makes this a no-op when everything is already saved. + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (timerRef.current) clearTimeout(timerRef.current) + void run() + } + }, [run]) + + return { status, flush, reset } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 259e4368..49199fd9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -340,6 +340,43 @@ export const joinTodo = async (id: string): Promise<Todo> => { return parseApiResponse(data) } +// ── Subtasks ── child tasks that live only inside a parent task's branch ────────── + +/** Lists a task's subtasks (oldest first). Visible to anyone who can see the parent. */ +export const fetchSubtasks = async (parentTodoId: string): Promise<Todo[]> => { + const { data } = await api.get<ApiResponse<Todo[]>>(`/todos/api/v1/todos/${parentTodoId}/subtasks`) + return parseApiResponse(data) +} + +/** + * Creates a subtask under a task (owner-only). Category/visibility are inherited server-side. + * Priority is intentionally not part of the subtask UX — when omitted the server defaults it. + */ +export const createSubtask = async ( + parentTodoId: string, + payload: { title: string; description?: string | null; priority?: number }, +): Promise<Todo> => { + const { data } = await api.post<ApiResponse<Todo>>( + `/todos/api/v1/todos/${parentTodoId}/subtasks`, + payload, + ) + return parseApiResponse(data) +} + +/** Updates a subtask's own fields (title, priority, status). Reuses the todo update endpoint. */ +export const updateSubtask = async ( + id: string, + payload: { title?: string; description?: string | null; priority?: number; status?: "todo" | "inprogress" | "done" }, +): Promise<Todo> => { + const { data } = await api.put<ApiResponse<Todo>>(`/todos/api/v1/todos/${id}`, payload) + return parseApiResponse(data) +} + +/** Deletes a subtask. */ +export const deleteSubtask = async (id: string): Promise<void> => { + await api.delete(`/todos/api/v1/todos/${id}`) +} + export const leaveTodo = async (id: string): Promise<void> => { await api.post(`/todos/api/v1/todos/${id}/leave`) } diff --git a/frontend/src/test/components/autosave-indicator.test.tsx b/frontend/src/test/components/autosave-indicator.test.tsx new file mode 100644 index 00000000..19b592de --- /dev/null +++ b/frontend/src/test/components/autosave-indicator.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { AutosaveIndicator } from "@/components/ui/autosave-indicator" + +describe("AutosaveIndicator", () => { + it("renders the resting label and exposes a polite live region", () => { + render(<AutosaveIndicator status="idle" />) + const status = screen.getByRole("status") + expect(status).toHaveAttribute("aria-live", "polite") + expect(status).toHaveTextContent("Changes save automatically") + }) + + it("honours a custom idle label", () => { + render(<AutosaveIndicator status="idle" idleLabel="Edits sync instantly" />) + expect(screen.getByText("Edits sync instantly")).toBeInTheDocument() + }) + + it("announces saving, saved and error states", () => { + const { rerender } = render(<AutosaveIndicator status="saving" />) + expect(screen.getByText("Saving…")).toBeInTheDocument() + + rerender(<AutosaveIndicator status="saved" />) + expect(screen.getByText("All changes saved")).toBeInTheDocument() + + rerender(<AutosaveIndicator status="error" />) + expect(screen.getByText("Couldn’t save — will retry")).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/components/todo-heavy-components.test.tsx b/frontend/src/test/components/todo-heavy-components.test.tsx index 98c05be4..93c56ff4 100644 --- a/frontend/src/test/components/todo-heavy-components.test.tsx +++ b/frontend/src/test/components/todo-heavy-components.test.tsx @@ -661,7 +661,7 @@ describe("EditTodoModal", () => { resetAuthState() }) - it("saves owner edits with normalized payload and closes on Escape/backdrop", async () => { + it("autosaves owner edits with a normalized payload and exposes no Save/Cancel buttons", async () => { const user = userEvent.setup() const onSave = vi.fn().mockResolvedValue(undefined) const onClose = vi.fn() @@ -678,6 +678,12 @@ describe("EditTodoModal", () => { />, ) + // Quick-save UX: every change autosaves, so there is no manual Save/Cancel (and the footer + // status panel was removed — the modal closes via the header ✕ or Escape). + expect(screen.queryByRole("button", { name: "Save" })).toBeNull() + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull() + expect(screen.queryByText("Changes save automatically")).toBeNull() + // Title is an <h1>; click it to enter edit mode, then change the textarea value await user.click(screen.getByRole("heading", { level: 1 })) // Title textarea has no placeholder; compose textarea does — first textbox is the title @@ -685,10 +691,9 @@ describe("EditTodoModal", () => { fireEvent.change(titleTextarea, { target: { value: "Updated task" } }) fireEvent.blur(titleTextarea) - await user.click(screen.getByRole("button", { name: "Save" })) - - await waitFor(() => expect(onSave).toHaveBeenCalledOnce()) - expect(onSave).toHaveBeenCalledWith({ + // No button click — the change autosaves on its own (debounced) + await waitFor(() => expect(onSave).toHaveBeenCalled(), { timeout: 2000 }) + expect(onSave).toHaveBeenLastCalledWith({ title: "Updated task", description: "Cover every important branch with focused behavior tests.", priority: 4, @@ -700,14 +705,15 @@ describe("EditTodoModal", () => { clearRequiredWorkers: false, }) - // handleSave calls onClose once; Escape triggers a second call - expect(onClose).toHaveBeenCalledTimes(1) + // Autosave keeps the modal open; closing is a separate, explicit action + expect(onClose).not.toHaveBeenCalled() await user.keyboard("{Escape}") - expect(onClose).toHaveBeenCalledTimes(2) + expect(onClose).toHaveBeenCalledTimes(1) }) - it("lets a shared viewer save only their private category preference", async () => { + it("autosaves only a shared viewer's private category preference", async () => { resetAuthState("friend-1") + const user = userEvent.setup() const onSave = vi.fn() const onSaveViewerPreference = vi.fn().mockResolvedValue(undefined) @@ -726,31 +732,40 @@ describe("EditTodoModal", () => { // Non-owner sees title as a heading (not an editable input) expect(screen.getByRole("heading", { level: 1, name: "Write coverage tests" })).toBeInTheDocument() - await userEvent.click(screen.getByRole("button", { name: "Save" })) + // Changing the viewer's own category autosaves a viewer preference — never the task + await user.click(screen.getByRole("button", { name: "Work" })) + await user.click(await screen.findByRole("button", { name: "Home" })) - await waitFor(() => expect(onSaveViewerPreference).toHaveBeenCalledOnce()) - expect(onSaveViewerPreference).toHaveBeenCalledWith({ viewerCategoryId: "cat-1" }) + await waitFor(() => expect(onSaveViewerPreference).toHaveBeenCalled(), { timeout: 2000 }) + expect(onSaveViewerPreference).toHaveBeenLastCalledWith({ viewerCategoryId: "cat-2" }) expect(onSave).not.toHaveBeenCalled() }) - it("blocks non-owner saves when the task is not public", () => { + it("is view-only and autosaves nothing when a non-owner cannot edit", () => { resetAuthState("stranger-1") + const onSave = vi.fn() + const onSaveViewerPreference = vi.fn() render( <EditTodoModal todo={baseTodo({ isPublic: false, sharedWithUserIds: [] })} categories={categories} onClose={vi.fn()} - onSave={vi.fn()} - onSaveViewerPreference={vi.fn()} + onSave={onSave} + onSaveViewerPreference={onSaveViewerPreference} onCreateCategory={vi.fn()} onDeleteCategory={vi.fn()} />, ) - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled() + // A stranger sees the title as a read-only heading (no editable textbox) and the footer + // status panel is gone, so the only signal is that nothing is editable / persisted. + expect(screen.getByRole("heading", { level: 1, name: "Write coverage tests" })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Save" })).toBeNull() + expect(onSave).not.toHaveBeenCalled() + expect(onSaveViewerPreference).not.toHaveBeenCalled() }) - it("creates a category inline while saving owner edits", async () => { + it("autosaves the owner payload after creating a category inline", async () => { const user = userEvent.setup() const onSave = vi.fn().mockResolvedValue(undefined) const onCreateCategory = vi.fn().mockResolvedValue(undefined) @@ -800,11 +815,9 @@ describe("EditTodoModal", () => { })) expect(onCreateCategory).toHaveBeenCalledOnce() - // Save the todo with the new category - await user.click(screen.getByRole("button", { name: "Save" })) - - await waitFor(() => expect(onSave).toHaveBeenCalledOnce()) - expect(onSave).toHaveBeenCalledWith({ + // Selecting the freshly created category autosaves the owner payload — no Save click + await waitFor(() => expect(onSave).toHaveBeenCalled(), { timeout: 2000 }) + expect(onSave).toHaveBeenLastCalledWith({ title: "Write coverage tests", description: null, priority: 3, diff --git a/frontend/src/test/hooks/use-autosave.test.tsx b/frontend/src/test/hooks/use-autosave.test.tsx new file mode 100644 index 00000000..45a47a94 --- /dev/null +++ b/frontend/src/test/hooks/use-autosave.test.tsx @@ -0,0 +1,173 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useAutosave, type UseAutosaveOptions } from "@/hooks/use-autosave" + +/** + * Drive the hook with mutable options so a single render can change `value` + * (mirroring how a form's state feeds the hook) and assert the resulting saves. + */ +function setup<T>(initial: UseAutosaveOptions<T>) { + const onSave = vi.fn(initial.onSave) + const { result, rerender, unmount } = renderHook( + (props: UseAutosaveOptions<T>) => useAutosave(props), + { initialProps: { ...initial, onSave } as UseAutosaveOptions<T> }, + ) + return { + result, + onSave, + unmount, + update: (next: Partial<UseAutosaveOptions<T>>) => + rerender({ ...initial, onSave, ...next } as UseAutosaveOptions<T>), + } +} + +describe("useAutosave", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it("does not save the initial value (the baseline is the opening state)", async () => { + const { onSave } = setup({ value: "hello", onSave: vi.fn().mockResolvedValue(undefined), delay: 300 }) + await act(async () => { await vi.advanceTimersByTimeAsync(500) }) + expect(onSave).not.toHaveBeenCalled() + }) + + it("debounces a burst of changes into a single save of the latest value", async () => { + const { onSave, update } = setup<string>({ value: "a", onSave: vi.fn().mockResolvedValue(undefined), delay: 300 }) + + update({ value: "ab" }) + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + update({ value: "abc" }) + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + update({ value: "abcd" }) + expect(onSave).not.toHaveBeenCalled() // still within the debounce window + + await act(async () => { await vi.advanceTimersByTimeAsync(300) }) + expect(onSave).toHaveBeenCalledTimes(1) + expect(onSave).toHaveBeenCalledWith("abcd") + }) + + it("reports status transitions idle → saving → saved", async () => { + let resolve!: () => void + const onSave = vi.fn().mockImplementation(() => new Promise<void>((r) => { resolve = () => r() })) + const { result, update } = setup<string>({ value: "a", onSave, delay: 100 }) + + expect(result.current.status).toBe("idle") + update({ value: "b" }) + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + expect(result.current.status).toBe("saving") + + await act(async () => { resolve() }) + expect(result.current.status).toBe("saved") + }) + + it("never re-saves a value that reverts back to the baseline", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { update } = setup<string>({ value: "a", onSave, delay: 100 }) + + update({ value: "b" }) + update({ value: "a" }) // reverted before the debounce fired + await act(async () => { await vi.advanceTimersByTimeAsync(200) }) + expect(onSave).not.toHaveBeenCalled() + }) + + it("skips saving when validate() rejects the value", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { update } = setup<string>({ + value: "ok", + onSave, + delay: 100, + validate: (v) => v.trim().length > 0, + }) + + update({ value: " " }) // fails validation + await act(async () => { await vi.advanceTimersByTimeAsync(200) }) + expect(onSave).not.toHaveBeenCalled() + }) + + it("does nothing while disabled, then resumes once enabled", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { update } = setup<string>({ value: "a", onSave, delay: 100, enabled: false }) + + update({ value: "b", enabled: false }) + await act(async () => { await vi.advanceTimersByTimeAsync(200) }) + expect(onSave).not.toHaveBeenCalled() + + update({ value: "c", enabled: true }) + await act(async () => { await vi.advanceTimersByTimeAsync(200) }) + expect(onSave).toHaveBeenCalledWith("c") + }) + + it("surfaces an error status when the save rejects", async () => { + const onSave = vi.fn().mockRejectedValue(new Error("boom")) + const { result, update } = setup<string>({ value: "a", onSave, delay: 100 }) + + update({ value: "b" }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + expect(result.current.status).toBe("error") + }) + + it("flush() persists a pending change immediately, cancelling the debounce", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { result, update } = setup<string>({ value: "a", onSave, delay: 1000 }) + + update({ value: "b" }) + await act(async () => { await result.current.flush() }) + expect(onSave).toHaveBeenCalledWith("b") + }) + + it("flush() is a no-op when nothing changed", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { result } = setup<string>({ value: "a", onSave, delay: 100 }) + + await act(async () => { await result.current.flush() }) + expect(onSave).not.toHaveBeenCalled() + }) + + it("reset() re-anchors the baseline so the new value is not seen as dirty", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { result, update } = setup<string>({ value: "a", onSave, delay: 100 }) + + // Re-anchor to "b", then feed "b" as the value — must NOT trigger a save. + act(() => { result.current.reset("b") }) + update({ value: "b" }) + await act(async () => { await vi.advanceTimersByTimeAsync(200) }) + expect(onSave).not.toHaveBeenCalled() + }) + + it("re-persists a change that arrives while a save is in flight (single-flight)", async () => { + const resolvers: Array<() => void> = [] + const onSave = vi.fn().mockImplementation( + () => new Promise<void>((r) => resolvers.push(() => r())), + ) + const { update } = setup<string>({ value: "a", onSave, delay: 100 }) + + update({ value: "b" }) + await act(async () => { await vi.advanceTimersByTimeAsync(100) }) + expect(onSave).toHaveBeenCalledTimes(1) + expect(onSave).toHaveBeenLastCalledWith("b") + + // Change again mid-flight, then let the first save settle. + update({ value: "c" }) + await act(async () => { resolvers[0]() }) + + // The newer value is persisted right after the in-flight save settles — no overlap. + expect(onSave).toHaveBeenCalledTimes(2) + expect(onSave).toHaveBeenLastCalledWith("c") + await act(async () => { resolvers[1]?.() }) + }) + + it("flushes a pending change on unmount so nothing is lost", async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const { update, unmount } = setup<string>({ value: "a", onSave, delay: 1000 }) + + update({ value: "b" }) + await act(async () => { unmount() }) + expect(onSave).toHaveBeenCalledWith("b") + }) +}) diff --git a/frontend/src/test/lib/subtasks-api.test.ts b/frontend/src/test/lib/subtasks-api.test.ts new file mode 100644 index 00000000..bd04277a --- /dev/null +++ b/frontend/src/test/lib/subtasks-api.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { api, fetchSubtasks, createSubtask, updateSubtask, deleteSubtask } from "@/lib/api" + +describe("subtask API helpers", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("fetchSubtasks GETs the parent's subtasks endpoint and unwraps the list", async () => { + const get = vi.spyOn(api, "get").mockResolvedValue({ + data: { success: true, data: [{ id: "s1", title: "Step 1", parentTodoId: "p1" }] }, + } as never) + + const result = await fetchSubtasks("p1") + + expect(get).toHaveBeenCalledWith("/todos/api/v1/todos/p1/subtasks") + expect(result).toEqual([{ id: "s1", title: "Step 1", parentTodoId: "p1" }]) + }) + + it("createSubtask POSTs title + numeric priority to the subtasks endpoint", async () => { + const post = vi.spyOn(api, "post").mockResolvedValue({ + data: { value: { id: "s2", title: "Step 2", priority: "Urgent", parentTodoId: "p1" } }, + } as never) + + const created = await createSubtask("p1", { title: "Step 2", priority: 5 }) + + expect(post).toHaveBeenCalledWith("/todos/api/v1/todos/p1/subtasks", { title: "Step 2", priority: 5 }) + expect(created.id).toBe("s2") + }) + + it("updateSubtask PUTs to the shared todo update endpoint by subtask id", async () => { + const put = vi.spyOn(api, "put").mockResolvedValue({ + data: { data: { id: "s1", status: "Done" } }, + } as never) + + await updateSubtask("s1", { status: "done" }) + + expect(put).toHaveBeenCalledWith("/todos/api/v1/todos/s1", { status: "done" }) + }) + + it("deleteSubtask DELETEs the subtask by id", async () => { + const del = vi.spyOn(api, "delete").mockResolvedValue({ data: {} } as never) + + await deleteSubtask("s1") + + expect(del).toHaveBeenCalledWith("/todos/api/v1/todos/s1") + }) +}) diff --git a/frontend/src/types/todo.ts b/frontend/src/types/todo.ts index 302ef687..5ad69123 100644 --- a/frontend/src/types/todo.ts +++ b/frontend/src/types/todo.ts @@ -104,6 +104,8 @@ export type Todo = { isWorking?: boolean workerUserIds?: string[] | null isCompletedByViewer?: boolean | null + /** When set, this todo is a subtask (child) of the given parent task. */ + parentTodoId?: string | null } export type PagedTodosResponse = { diff --git a/tests/Planora.ErrorHandlingTests/Integration/ErrorHandlingIntegrationTests.cs b/tests/Planora.ErrorHandlingTests/Integration/ErrorHandlingIntegrationTests.cs index 4d0ba65f..696ec36b 100644 --- a/tests/Planora.ErrorHandlingTests/Integration/ErrorHandlingIntegrationTests.cs +++ b/tests/Planora.ErrorHandlingTests/Integration/ErrorHandlingIntegrationTests.cs @@ -194,14 +194,16 @@ public async Task UpdateTodo_WithTitleTooLong_ReturnsValidationError() { var id = await CreateTodoAsync("Update validation integration todo"); + // The update path also edits subtask titles, which allow up to 1500 chars, so the cap here + // is 1500 (regular-task titles are kept to 200 by the create validator + UI). var response = await _client.PutAsJsonAsync($"/api/v1/todos/{id}", new { - title = new string('b', 201) + title = new string('b', 1501) }); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); using var body = await ReadJsonAsync(response); - AssertFailureEnvelope(body, "VALIDATION.INVALID_INPUT", "Title cannot exceed 200 characters"); + AssertFailureEnvelope(body, "VALIDATION.INVALID_INPUT", "Title cannot exceed 1500 characters"); } [Fact] diff --git a/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs b/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs index 4d0154a7..50e705c5 100644 --- a/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs +++ b/tests/Planora.UnitTests/Services/CollaborationApi/IntegrationEvents/IntegrationEventConsumerTests.cs @@ -73,6 +73,33 @@ await consumer.HandleAsync( Assert.Contains("Carol", captured.Content); } + [Theory] + [Trait("TestType", "Functional")] + [InlineData(TaskActivityType.SubtaskCreated, "added a subtask: Draft outline")] + [InlineData(TaskActivityType.SubtaskCompleted, "completed a subtask: Draft outline")] + public async Task SubtaskActivity_WritesSystemCommentWithTitle(string activity, string expectedFragment) + { + var parentId = Guid.NewGuid(); + var comments = new Mock<ICommentRepository>(); + Comment? captured = null; + comments.Setup(x => x.AddAsync(It.IsAny<Comment>(), It.IsAny<CancellationToken>())) + .Callback<Comment, CancellationToken>((c, _) => captured = c) + .ReturnsAsync((Comment c, CancellationToken _) => c); + + var consumer = new TaskActivityEventConsumer(comments.Object, Mock.Of<IUnitOfWork>(), + Mock.Of<ILogger<TaskActivityEventConsumer>>()); + + await consumer.HandleAsync( + new TaskActivityIntegrationEvent(parentId, Guid.NewGuid(), "Dave", activity, "Draft outline"), + CancellationToken.None); + + Assert.NotNull(captured); + Assert.True(captured!.IsSystemComment); + Assert.Equal(parentId, captured.TaskId); // posted to the PARENT's branch + Assert.Contains("Dave", captured.Content); + Assert.Contains(expectedFragment, captured.Content); + } + [Fact] [Trait("TestType", "Resilience")] public async Task TaskActivity_UnknownType_IsSkippedSilently() @@ -107,6 +134,33 @@ public async Task TaskDeleted_CascadeSoftDeletesTimeline() uow.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); } + // ─── SubtaskDeleted ───────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Integration")] + public async Task SubtaskDeleted_SoftDeletesOnlyTheSubtaskAnnouncementsInParentBranch() + { + var parentId = Guid.NewGuid(); + var subtaskId = Guid.NewGuid(); + var actor = Guid.NewGuid(); + var comments = new Mock<ICommentRepository>(); + var uow = new Mock<IUnitOfWork>(); + + var consumer = new SubtaskDeletedEventConsumer(comments.Object, uow.Object, + Mock.Of<ILogger<SubtaskDeletedEventConsumer>>()); + + await consumer.HandleAsync( + new SubtaskDeletedIntegrationEvent(parentId, subtaskId, actor, "Draft outline"), + CancellationToken.None); + + // Targets the parent branch + title (not a whole-branch wipe). + comments.Verify(x => x.SoftDeleteSubtaskActivityAsync( + parentId, "Draft outline", actor, It.IsAny<CancellationToken>()), Times.Once); + comments.Verify(x => x.SoftDeleteByTaskIdAsync( + It.IsAny<Guid>(), It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never); + uow.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); + } + // ─── UserDeleted ──────────────────────────────────────────────────────────── [Fact] diff --git a/tests/Planora.UnitTests/Services/Infrastructure/EfModelConfigurationTests.cs b/tests/Planora.UnitTests/Services/Infrastructure/EfModelConfigurationTests.cs index 2274eb30..47e59945 100644 --- a/tests/Planora.UnitTests/Services/Infrastructure/EfModelConfigurationTests.cs +++ b/tests/Planora.UnitTests/Services/Infrastructure/EfModelConfigurationTests.cs @@ -81,7 +81,8 @@ public void TodoDbContextModel_ShouldApplyTodoPersistenceContracts() var todo = RequireEntity<TodoItem>(model); Assert.Equal("todo", todo.GetSchema()); Assert.NotNull(todo.FindPrimaryKey()); - Assert.Equal(200, todo.FindProperty(nameof(TodoItem.Title))?.GetMaxLength()); + // 1500 to hold a subtask's full content (a subtask's text lives in its title). + Assert.Equal(1500, todo.FindProperty(nameof(TodoItem.Title))?.GetMaxLength()); Assert.Equal(2000, todo.FindProperty(nameof(TodoItem.Description))?.GetMaxLength()); Assert.Equal(TodoStatus.Todo, todo.FindProperty(nameof(TodoItem.Status))!.GetDefaultValue()); Assert.Equal(TodoPriority.Medium, todo.FindProperty(nameof(TodoItem.Priority))!.GetDefaultValue()); diff --git a/tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemWorkerTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemWorkerTests.cs index abf23018..47de9213 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemWorkerTests.cs +++ b/tests/Planora.UnitTests/Services/TodoApi/Domain/TodoItemWorkerTests.cs @@ -31,6 +31,20 @@ public void AddWorker_WhenOwner_ShouldThrow() Assert.Throws<BusinessRuleViolationException>(() => todo.AddWorker(ownerId)); } + [Fact] + public void AddWorker_OnSubtask_AllowsOwner() + { + // A subtask's in-work is per-user (everyone, owner included, opts in), so the owner-always- + // worker rule does NOT apply to subtasks — the owner may hold a worker row and be counted. + var ownerId = Guid.NewGuid(); + var parent = TodoItem.Create(ownerId, "Parent"); + var subtask = TodoItem.CreateSubtask(parent, ownerId, "child", null); + + subtask.AddWorker(ownerId); + + Assert.Contains(subtask.Workers, w => w.UserId == ownerId); + } + [Fact] public void AddWorker_WhenAlreadyWorker_ShouldThrow() { diff --git a/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs index 3859c618..86ef39b8 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs +++ b/tests/Planora.UnitTests/Services/TodoApi/Handlers/TodoCommandHandlerExpandedTests.cs @@ -8,6 +8,8 @@ using Planora.Todo.Application.Features.Todos.Commands.SetTodoHidden; using Planora.Todo.Application.Features.Todos.Commands.SetViewerPreference; using Planora.Todo.Application.Features.Todos.Commands.UpdateTodo; +using Planora.Todo.Application.Features.Todos.Commands.CreateSubtask; +using Planora.Todo.Application.Features.Todos.Queries.GetSubtasks; using Planora.Todo.Application.Interfaces; using Planora.Todo.Application.Services; using Planora.Todo.Domain.Entities; @@ -133,7 +135,7 @@ public async Task DeleteTodo_ShouldSoftDeleteOwnedTodoAndPersist() var userId = Guid.NewGuid(); var todo = TodoItem.Create(userId, "Owned task"); var fixture = new TodoCommandFixture(userId); - fixture.GenericRepository + fixture.TodoRepository .Setup(x => x.GetByIdAsync(todo.Id, It.IsAny<CancellationToken>())) .ReturnsAsync(todo); @@ -144,10 +146,40 @@ public async Task DeleteTodo_ShouldSoftDeleteOwnedTodoAndPersist() Assert.True(result.IsSuccess); Assert.True(todo.IsDeleted); Assert.Equal(userId, todo.DeletedBy); - fixture.GenericRepository.Verify(x => x.Update(todo), Times.Once); + fixture.TodoRepository.Verify(x => x.Update(todo), Times.Once); fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); } + [Fact] + [Trait("TestType", "Functional")] + public async Task DeleteTodo_OnSubtask_EnqueuesSubtaskDeletedForParentBranch() + { + // Deleting a subtask must remove only its announcement comments from the PARENT's branch + // (SubtaskDeletedIntegrationEvent), never wipe a whole branch (TaskDeletedIntegrationEvent). + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(userId, "Parent"); + var subtask = TodoItem.CreateSubtask(parent, userId, "Draft outline", null); + var fixture = new TodoCommandFixture(userId); + + Planora.BuildingBlocks.Application.Outbox.OutboxMessage? captured = null; + fixture.OutboxRepository + .Setup(x => x.AddAsync(It.IsAny<Planora.BuildingBlocks.Application.Outbox.OutboxMessage>(), It.IsAny<CancellationToken>())) + .Callback<Planora.BuildingBlocks.Application.Outbox.OutboxMessage, CancellationToken>((m, _) => captured = m); + fixture.TodoRepository + .Setup(x => x.GetByIdAsync(subtask.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtask); + + var result = await fixture.CreateDeleteHandler().Handle( + new DeleteTodoCommand(subtask.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(subtask.IsDeleted); + Assert.NotNull(captured); + Assert.Contains(nameof(Planora.BuildingBlocks.Application.Messaging.Events.SubtaskDeletedIntegrationEvent), captured!.Type); + Assert.Contains("Draft outline", captured.Content); // title for suffix matching + Assert.Contains(parent.Id.ToString(), captured.Content); // targets the parent branch + } + [Fact] [Trait("TestType", "Security")] [Trait("TestType", "Regression")] @@ -156,7 +188,7 @@ public async Task DeleteTodo_ShouldRejectMissingAndForeignTodoBeforePersisting() var userId = Guid.NewGuid(); var todoId = Guid.NewGuid(); var fixture = new TodoCommandFixture(userId); - fixture.GenericRepository + fixture.TodoRepository .Setup(x => x.GetByIdAsync(todoId, It.IsAny<CancellationToken>())) .ReturnsAsync((TodoItem?)null); @@ -164,7 +196,7 @@ await Assert.ThrowsAsync<EntityNotFoundException>(() => fixture.CreateDeleteHandler().Handle(new DeleteTodoCommand(todoId), CancellationToken.None)); var foreign = TodoItem.Create(Guid.NewGuid(), "Foreign task"); - fixture.GenericRepository + fixture.TodoRepository .Setup(x => x.GetByIdAsync(foreign.Id, It.IsAny<CancellationToken>())) .ReturnsAsync(foreign); @@ -172,7 +204,7 @@ await Assert.ThrowsAsync<ForbiddenException>(() => fixture.CreateDeleteHandler().Handle(new DeleteTodoCommand(foreign.Id), CancellationToken.None)); Assert.False(foreign.IsDeleted); - fixture.GenericRepository.Verify(x => x.Update(It.IsAny<TodoItem>()), Times.Never); + fixture.TodoRepository.Verify(x => x.Update(It.IsAny<TodoItem>()), Times.Never); fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never); } @@ -643,6 +675,249 @@ public async Task UpdateTodo_ShouldPersistVisibility_WhenPrivateTaskSharedWithSp fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); } + // ── Subtasks ────────────────────────────────────────────────────────────── + + [Fact] + [Trait("TestType", "Functional")] + public async Task CreateSubtask_InheritsParentCategoryVisibilityShares_WithOwnPriorityAndNoDates() + { + var userId = Guid.NewGuid(); + var friendId = Guid.NewGuid(); + var categoryId = Guid.NewGuid(); + var parent = TodoItem.Create( + userId, "Parent", "desc", categoryId, + DateTime.UtcNow.AddDays(3), null, TodoPriority.Low, + isPublic: true, sharedWithUserIds: new[] { friendId }); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(parent); + TodoItem? added = null; + fixture.TodoRepository + .Setup(x => x.AddAsync(It.IsAny<TodoItem>(), It.IsAny<CancellationToken>())) + .Callback<TodoItem, CancellationToken>((t, _) => added = t) + .ReturnsAsync((TodoItem t, CancellationToken _) => t); + fixture.CategoryGrpcClient + .Setup(x => x.GetCategoryInfoAsync(categoryId, userId, It.IsAny<CancellationToken>())) + .ReturnsAsync(new CategoryInfo(categoryId, userId, "Work", "#fff", "icon")); + + var result = await fixture.CreateSubtaskHandler().Handle( + new CreateSubtaskCommand(parent.Id, " Step 1 ", "note", TodoPriority.Urgent), + CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.NotNull(added); + Assert.Equal(parent.Id, added!.ParentTodoId); + Assert.True(added.IsSubtask); + Assert.Equal("Step 1", added.Title); + Assert.Equal(categoryId, added.CategoryId); // inherited + Assert.True(added.IsPublic); // inherited + Assert.Contains(added.SharedWith, s => s.SharedWithUserId == friendId); // inherited + Assert.Equal(TodoPriority.Urgent, added.Priority); // own + Assert.Null(added.DueDate); // never + Assert.Null(added.ExpectedDate); // never + Assert.Equal("Work", result.Value!.CategoryName); + fixture.UnitOfWork.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once); + // Subtask creation is silent — no system message is enqueued for the parent's branch. + fixture.OutboxRepository.Verify( + x => x.AddAsync(It.IsAny<Planora.BuildingBlocks.Application.Outbox.OutboxMessage>(), It.IsAny<CancellationToken>()), + Times.Never); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task CreateSubtask_RejectsForeignParent() + { + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(Guid.NewGuid(), "Foreign"); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(parent); + + await Assert.ThrowsAsync<ForbiddenException>(() => + fixture.CreateSubtaskHandler().Handle(new CreateSubtaskCommand(parent.Id, "x"), CancellationToken.None)); + fixture.TodoRepository.Verify(x => x.AddAsync(It.IsAny<TodoItem>(), It.IsAny<CancellationToken>()), Times.Never); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task CreateSubtask_RejectsNestingUnderSubtask() + { + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(userId, "Parent"); + var subtask = TodoItem.CreateSubtask(parent, userId, "child", null); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(subtask.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtask); + + await Assert.ThrowsAsync<ForbiddenException>(() => + fixture.CreateSubtaskHandler().Handle(new CreateSubtaskCommand(subtask.Id, "grandchild"), CancellationToken.None)); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task UpdateTodo_OnSubtask_RejectsInheritedFieldChanges_ButAllowsPriority() + { + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(userId, "Parent", categoryId: Guid.NewGuid()); + var subtask = TodoItem.CreateSubtask(parent, userId, "child", null, TodoPriority.Low); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(subtask.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtask); + + await Assert.ThrowsAsync<ForbiddenException>(() => + fixture.CreateUpdateHandler().Handle( + new UpdateTodoCommand(subtask.Id, CategoryId: Guid.NewGuid()), CancellationToken.None)); + + var ok = await fixture.CreateUpdateHandler().Handle( + new UpdateTodoCommand(subtask.Id, Priority: TodoPriority.Urgent), CancellationToken.None); + Assert.True(ok.IsSuccess); + Assert.Equal(TodoPriority.Urgent, subtask.Priority); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task UpdateTodo_OnParent_PropagatesVisibilityToSubtasks() + { + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(userId, "Parent", isPublic: true); + var child1 = TodoItem.CreateSubtask(parent, userId, "c1", null); + var child2 = TodoItem.CreateSubtask(parent, userId, "c2", null); + Assert.True(child1.IsPublic); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(parent); + fixture.TodoRepository + .Setup(x => x.GetSubtasksTrackedAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(new[] { child1, child2 }); + + var result = await fixture.CreateUpdateHandler().Handle( + new UpdateTodoCommand(parent.Id, IsPublic: false), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(parent.IsPublic); + Assert.False(child1.IsPublic); + Assert.False(child2.IsPublic); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task DeleteTodo_SoftDeletesSubtasksWithParent() + { + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(userId, "Parent"); + var child = TodoItem.CreateSubtask(parent, userId, "c1", null); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(parent); + fixture.TodoRepository + .Setup(x => x.GetSubtasksTrackedAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(new[] { child }); + + var result = await fixture.CreateDeleteHandler().Handle(new DeleteTodoCommand(parent.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(parent.IsDeleted); + Assert.True(child.IsDeleted); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task UpdateTodo_NonOwnerCompletesSubtask_GloballyNotPerViewer() + { + var ownerId = Guid.NewGuid(); + var friendId = Guid.NewGuid(); + // Public parent so the friend has access; subtask inherits public. + var parent = TodoItem.Create(ownerId, "Parent", isPublic: true); + var subtask = TodoItem.CreateSubtask(parent, ownerId, "Shared step", null); + var fixture = new TodoCommandFixture(friendId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(subtask.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtask); + fixture.FriendshipService + .Setup(x => x.AreFriendsAsync(friendId, ownerId, It.IsAny<CancellationToken>())) + .ReturnsAsync(true); + + var result = await fixture.CreateUpdateHandler().Handle( + new UpdateTodoCommand(subtask.Id, Status: "done"), CancellationToken.None); + + Assert.True(result.IsSuccess); + // Global completion: the entity status itself flips to Done (not a per-viewer row). + Assert.True(subtask.IsCompleted); + fixture.TodoRepository.Verify(x => x.Update(subtask), Times.Once); + fixture.ViewerPreferences.Verify( + x => x.UpsertAsync(It.IsAny<UserTodoViewPreference>(), It.IsAny<CancellationToken>()), Times.Never); + // A "X completed a subtask" system message is enqueued for the parent's branch. + fixture.OutboxRepository.Verify( + x => x.AddAsync(It.IsAny<Planora.BuildingBlocks.Application.Outbox.OutboxMessage>(), It.IsAny<CancellationToken>()), + Times.Once); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task UpdateTodo_NonOwnerCannotEditSubtaskTitleOrPriority() + { + var ownerId = Guid.NewGuid(); + var friendId = Guid.NewGuid(); + var parent = TodoItem.Create(ownerId, "Parent", isPublic: true); + var subtask = TodoItem.CreateSubtask(parent, ownerId, "Shared step", null); + var fixture = new TodoCommandFixture(friendId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesTrackedAsync(subtask.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(subtask); + fixture.FriendshipService + .Setup(x => x.AreFriendsAsync(friendId, ownerId, It.IsAny<CancellationToken>())) + .ReturnsAsync(true); + + await Assert.ThrowsAsync<ForbiddenException>(() => + fixture.CreateUpdateHandler().Handle( + new UpdateTodoCommand(subtask.Id, Priority: TodoPriority.Urgent), CancellationToken.None)); + } + + [Fact] + [Trait("TestType", "Security")] + public async Task GetSubtasks_RejectsViewerWithoutAccessToPrivateParent() + { + var ownerId = Guid.NewGuid(); + var viewerId = Guid.NewGuid(); + var parent = TodoItem.Create(ownerId, "Private parent"); // not public, not shared + var fixture = new TodoCommandFixture(viewerId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(parent); + + await Assert.ThrowsAsync<ForbiddenException>(() => + fixture.CreateGetSubtasksHandler().Handle(new GetSubtasksQuery(parent.Id), CancellationToken.None)); + } + + [Fact] + [Trait("TestType", "Functional")] + public async Task GetSubtasks_ReturnsChildrenForOwner() + { + var userId = Guid.NewGuid(); + var parent = TodoItem.Create(userId, "Parent"); + var child = TodoItem.CreateSubtask(parent, userId, "c1", null); + var fixture = new TodoCommandFixture(userId); + fixture.TodoRepository + .Setup(x => x.GetByIdWithIncludesAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(parent); + fixture.TodoRepository + .Setup(x => x.GetSubtasksAsync(parent.Id, It.IsAny<CancellationToken>())) + .ReturnsAsync(new[] { child }); + + var result = await fixture.CreateGetSubtasksHandler().Handle(new GetSubtasksQuery(parent.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value!); + Assert.Equal(child.Id, result.Value![0].Id); + Assert.Equal(parent.Id, result.Value![0].ParentTodoId); + } + private sealed class TodoCommandFixture { public Mock<IRepository<TodoItem>> GenericRepository { get; } = new(); @@ -661,6 +936,14 @@ public TodoCommandFixture(Guid userId) CurrentUser.SetupGet(x => x.IsAuthenticated).Returns(userId != Guid.Empty); UnitOfWork.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1); Mapper.Setup(x => x.Map<TodoItemDto>(It.IsAny<TodoItem>())).Returns((TodoItem item) => ToDto(item)); + // Subtask lookups default to empty so parent update/delete propagation is a no-op + // unless a test opts in by stubbing these explicitly. + TodoRepository + .Setup(x => x.GetSubtasksTrackedAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(Array.Empty<TodoItem>()); + TodoRepository + .Setup(x => x.GetSubtasksAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(Array.Empty<TodoItem>()); } public CreateTodoCommandHandler CreateCreateHandler() @@ -684,7 +967,7 @@ public UpdateTodoCommandHandler CreateUpdateHandler() CategoryGrpcClient.Object, FriendshipService.Object, ViewerPreferences.Object, - Mock.Of<Planora.BuildingBlocks.Application.Outbox.IOutboxRepository>()); + OutboxRepository.Object); public SetTodoHiddenCommandHandler CreateSetHiddenHandler() => new( @@ -707,11 +990,29 @@ public SetViewerPreferenceCommandHandler CreateSetViewerPreferenceHandler() public DeleteTodoCommandHandler CreateDeleteHandler() => new( - GenericRepository.Object, + TodoRepository.Object, OutboxRepository.Object, UnitOfWork.Object, Mock.Of<ILogger<DeleteTodoCommandHandler>>(), CurrentUser.Object); + + public Planora.Todo.Application.Features.Todos.Commands.CreateSubtask.CreateSubtaskCommandHandler CreateSubtaskHandler() + => new( + TodoRepository.Object, + UnitOfWork.Object, + Mapper.Object, + Mock.Of<ILogger<Planora.Todo.Application.Features.Todos.Commands.CreateSubtask.CreateSubtaskCommandHandler>>(), + CurrentUser.Object, + CategoryGrpcClient.Object); + + public Planora.Todo.Application.Features.Todos.Queries.GetSubtasks.GetSubtasksQueryHandler CreateGetSubtasksHandler() + => new( + TodoRepository.Object, + CurrentUser.Object, + Mapper.Object, + Mock.Of<ILogger<Planora.Todo.Application.Features.Todos.Queries.GetSubtasks.GetSubtasksQueryHandler>>(), + FriendshipService.Object, + CategoryGrpcClient.Object); } private static TodoItemDto ToDto(TodoItem item) @@ -736,6 +1037,7 @@ private static TodoItemDto ToDto(TodoItem item) Tags = item.Tags.Select(tag => tag.Name).ToList(), CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt, - SharedWithUserIds = item.SharedWith.Select(share => share.SharedWithUserId).ToList() + SharedWithUserIds = item.SharedWith.Select(share => share.SharedWithUserId).ToList(), + ParentTodoId = item.ParentTodoId }; } diff --git a/tests/Planora.UnitTests/Services/TodoApi/Validators/TodoValidatorTests.cs b/tests/Planora.UnitTests/Services/TodoApi/Validators/TodoValidatorTests.cs index c0485bbd..dc71ed39 100644 --- a/tests/Planora.UnitTests/Services/TodoApi/Validators/TodoValidatorTests.cs +++ b/tests/Planora.UnitTests/Services/TodoApi/Validators/TodoValidatorTests.cs @@ -1,5 +1,6 @@ using Planora.Todo.Application.Features.Todos.Commands.CreateTodo; using Planora.Todo.Application.Features.Todos.Commands.UpdateTodo; +using Planora.Todo.Application.Features.Todos.Commands.CreateSubtask; using Planora.Todo.Domain.Enums; namespace Planora.UnitTests.Services.TodoApi.Validators; @@ -49,7 +50,8 @@ public void UpdateTodoValidator_ShouldRequireIdAndValidateOptionalFields() var result = validator.Validate(new UpdateTodoCommand( TodoId: Guid.Empty, - Title: new string('t', 201), + // Update allows subtask-sized titles (1500); exceed that to trigger the Title error. + Title: new string('t', 1501), Description: new string('d', 5001), DueDate: new DateTime(2026, 5, 1), ExpectedDate: new DateTime(2026, 5, 2))); @@ -74,4 +76,19 @@ public void UpdateTodoValidator_ShouldAcceptPartialMetadataPatch() Assert.True(result.IsValid); } + + [Fact] + public void CreateSubtaskValidator_AcceptsUpTo1500CharTitle_AndRejectsBeyond() + { + var validator = new CreateSubtaskCommandValidator(); + var parent = Guid.NewGuid(); + + // A subtask's whole content lives in its title, so it gets a 1500-char allowance. + var atLimit = validator.Validate(new CreateSubtaskCommand(parent, new string('s', 1500))); + Assert.True(atLimit.IsValid); + + var tooLong = validator.Validate(new CreateSubtaskCommand(parent, new string('s', 1501))); + Assert.False(tooLong.IsValid); + Assert.Contains(tooLong.Errors, e => e.ErrorMessage == "Title cannot exceed 1500 characters"); + } }