Fix project/milestone association and redesign AppHeader navigation

Backend:
- routes/tasks.py: POST + PUT were silently dropping project_id,
  milestone_id, parent_id from request body — root cause of association
  not saving from the task editor
- routes/tasks.py: GET /api/tasks/:id now includes parent_title in
  response (secondary lookup when parent_id is set)
- routes/notes.py: add PATCH /api/notes/:id for partial updates (used
  by sub-task status toggle; PUT already existed but PATCH was missing)
- routes/projects.py: GET /api/projects/:id/notes now fetches milestone
  IDs and passes them via milestone_ids so tasks assigned to a milestone
  (but lacking project_id) are included in the project view
- services/notes.py: create_note() auto-sets project_id from milestone
  when milestone_id is provided and project_id is omitted; list_notes()
  gains milestone_ids param — when combined with project_id uses OR
  condition (project_id=X OR milestone_id IN (...))

Frontend:
- NoteEditorView: add MilestoneSelector; milestone resets when project
  changes; all save paths (save/create/auto-save) include milestone_id
- stores/notes.ts: add milestone_id to createNote + updateNote types
- TaskEditorView: sub-tasks now inherit milestone_id from parent task
- AppHeader: three-zone layout — brand left, Notes/Projects/Tasks/Chat
  centered (absolute positioning), right rail with status/theme/? and
  gear dropdown containing Settings/Users/Logs; mobile dropdown unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Van Deusen
2026-03-03 13:34:01 -05:00
parent 47c9cb39a2
commit 87d3f3ea16
10 changed files with 309 additions and 120 deletions
+19
View File
@@ -215,6 +215,25 @@ async def update_note_route(note_id: int):
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
@login_required
async def patch_note_route(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
fields = {}
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority"):
if key in data:
fields[key] = data[key]
if "due_date" in data:
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
if note is None:
return jsonify({"error": "Note not found"}), 404
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required
async def delete_note_route(note_id: int):
+5
View File
@@ -4,6 +4,7 @@ import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import (
create_project,
@@ -101,11 +102,15 @@ async def get_project_notes_route(project_id: int):
elif type_filter == "note":
is_task = False
ms_list = await list_milestones(uid, project_id)
milestone_ids = [m.id for m in ms_list]
notes, total = await list_notes(
uid,
is_task=is_task,
status=status_filter,
project_id=project_id,
milestone_ids=milestone_ids,
limit=limit,
offset=offset,
sort="updated_at",
+12 -1
View File
@@ -81,6 +81,9 @@ async def create_task_route():
priority=priority,
due_date=due_date,
tags=tags,
project_id=data.get("project_id"),
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
)
return jsonify(task.to_dict()), 201
@@ -92,7 +95,11 @@ async def get_task_route(task_id: int):
task = await get_note(uid, task_id)
if task is None:
return jsonify({"error": "Task not found"}), 404
return jsonify(task.to_dict())
data = task.to_dict()
if task.parent_id:
parent = await get_note(uid, task.parent_id)
data["parent_title"] = parent.title if parent else None
return jsonify(data)
@tasks_bp.route("/<int:task_id>", methods=["PUT"])
@@ -123,6 +130,10 @@ async def update_task_route(task_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
for key in ("project_id", "milestone_id", "parent_id"):
if key in data:
fields[key] = data[key]
task = await update_note(uid, task_id, **fields)
if task is None:
return jsonify({"error": "Task not found"}), 404
+19 -2
View File
@@ -33,6 +33,17 @@ async def create_note(
priority: str | None = None,
due_date: date | None = None,
) -> Note:
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
from fabledassistant.models.milestone import Milestone
async with async_session() as lookup:
result = await lookup.execute(
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
)
ms = result.scalars().first()
if ms is not None:
project_id = ms.project_id
async with async_session() as session:
note = Note(
user_id=user_id,
@@ -71,6 +82,7 @@ async def list_notes(
due_after: date | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
milestone_ids: list[int] | None = None,
parent_id: int | None = None,
sort: str = "updated_at",
order: str = "desc",
@@ -125,8 +137,13 @@ async def list_notes(
count_query = count_query.where(Note.due_date >= due_after)
if project_id is not None:
query = query.where(Note.project_id == project_id)
count_query = count_query.where(Note.project_id == project_id)
if milestone_ids:
# OR: directly assigned to project, OR assigned to one of the project's milestones
project_filter = or_(Note.project_id == project_id, Note.milestone_id.in_(milestone_ids))
else:
project_filter = Note.project_id == project_id
query = query.where(project_filter)
count_query = count_query.where(project_filter)
if milestone_id is not None:
query = query.where(Note.milestone_id == milestone_id)