Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Add important and urgent priority fields to tasks

Revision ID: a1b2c3d4e5f6
Revises: 2fd06db2a9d8
Create Date: 2026-04-24 00:00:00.000000
"""
from collections.abc import Sequence
from typing import Union

import sqlalchemy as sa
from alembic import op

revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = '2fd06db2a9d8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column('tasks', sa.Column('important', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('tasks', sa.Column('urgent', sa.Boolean(), nullable=False, server_default='false'))
# Drop server_defaults after backfill so model and DB stay in sync
op.alter_column('tasks', 'important', server_default=None)
op.alter_column('tasks', 'urgent', server_default=None)
op.create_index(
'ix_tasks_priority_lookup',
'tasks',
['user_id', 'status', 'bucket', 'important', 'urgent'],
)


def downgrade() -> None:
op.drop_index('ix_tasks_priority_lookup', table_name='tasks')
op.drop_column('tasks', 'urgent')
op.drop_column('tasks', 'important')
50 changes: 50 additions & 0 deletions backend/app/api/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import uuid

from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func
from sqlmodel import Session, select

from app.core.deps import get_db
from app.core.security import get_current_user_id
from app.models.enums import TaskStatus
from app.models.task import Task

router = APIRouter(prefix="/state", tags=["state"])


class PriorityCounts(BaseModel):
q1_count: int
q2_count: int
q3_count: int
q4_count: int


class StateResponse(BaseModel):
priority: PriorityCounts


@router.get("", response_model=StateResponse)
def get_state(
db: Session = Depends(get_db),
user_id: uuid.UUID = Depends(get_current_user_id),
):
rows = db.exec(
select(Task.important, Task.urgent, func.count())
.where(
Task.user_id == user_id,
Task.status == TaskStatus.pending,
Task.parent_id.is_(None),
)
.group_by(Task.important, Task.urgent)
).all()

counts: dict[tuple[bool, bool], int] = {(imp, urg): cnt for imp, urg, cnt in rows}
return StateResponse(
priority=PriorityCounts(
q1_count=counts.get((True, True), 0),
q2_count=counts.get((True, False), 0),
q3_count=counts.get((False, True), 0),
q4_count=counts.get((False, False), 0),
)
)
25 changes: 25 additions & 0 deletions backend/app/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.models.enums import BucketType, TaskStatus
from app.schemas.task_schemas import (
DomainBrief,
PriorityUpdate,
ReorderRequest,
SubTaskResponse,
TaskCreate,
Expand Down Expand Up @@ -41,6 +42,8 @@ def _to_response(task, mit_task_id: uuid.UUID | None = None) -> TaskResponse:
updated_at=task.updated_at,
completed_at=task.completed_at,
is_mit=mit_task_id is not None and task.id == mit_task_id,
important=task.important,
urgent=task.urgent,
)


Expand Down Expand Up @@ -91,6 +94,8 @@ def create_task(
parent_id=body.parent_id,
notes=body.notes,
skip_triage_stamp=skip_stamp,
important=body.important,
urgent=body.urgent,
)
# Reload with relationships for response
task = task_service.get_task(db, user_id, task.id)
Expand Down Expand Up @@ -125,6 +130,26 @@ def update_task(
kwargs["status"] = body.status
if "notes" in body.model_fields_set:
kwargs["notes"] = body.notes
if body.important is not None:
kwargs["important"] = body.important
if body.urgent is not None:
kwargs["urgent"] = body.urgent
task = task_service.update_task(db, user_id, task_id, **kwargs)
return _to_response(task)


@router.post("/{task_id}/priority", response_model=TaskResponse)
def set_priority(
task_id: uuid.UUID,
body: PriorityUpdate,
db: Session = Depends(get_db),
user_id: uuid.UUID = Depends(get_current_user_id),
):
kwargs: dict = {}
if body.important is not None:
kwargs["important"] = body.important
if body.urgent is not None:
kwargs["urgent"] = body.urgent
task = task_service.update_task(db, user_id, task_id, **kwargs)
return _to_response(task)

Expand Down
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from slowapi.errors import RateLimitExceeded
from sqlalchemy import text

from app.api import account, billing, domains, tasks, triage
from app.api import account, billing, domains, state, tasks, triage
from app.core.config import settings
from app.core.deps import engine
from app.core.errors import AppError, app_error_handler
Expand All @@ -23,6 +23,7 @@
app.include_router(domains.router)
app.include_router(account.router)
app.include_router(billing.router)
app.include_router(state.router)

# Error handlers
app.add_exception_handler(AppError, app_error_handler)
Expand Down
7 changes: 6 additions & 1 deletion backend/app/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
from datetime import date, datetime
from typing import Optional

from sqlalchemy import Column, String
from sqlalchemy import Column, Index, String
from sqlmodel import Field, Relationship, SQLModel

from app.models.enums import BucketType, TaskStatus


class Task(SQLModel, table=True):
__tablename__ = "tasks"
__table_args__ = (
Index("ix_tasks_priority_lookup", "user_id", "status", "bucket", "important", "urgent"),
)

id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(foreign_key="users.id", ondelete="CASCADE")
Expand All @@ -20,6 +23,8 @@ class Task(SQLModel, table=True):
notes: str | None = Field(default=None, max_length=2000)
position: int | None = Field(default=None)
triaged_at: date | None = Field(default=None)
important: bool = Field(default=False, nullable=False)
urgent: bool = Field(default=False, nullable=False)

# Parent task (one level of sub-tasks)
parent_id: uuid.UUID | None = Field(
Expand Down
11 changes: 11 additions & 0 deletions backend/app/schemas/task_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@
from app.models.enums import BucketType, TaskStatus


class PriorityUpdate(BaseModel):
important: bool | None = None
urgent: bool | None = None


class TaskCreate(BaseModel):
text: str
bucket: BucketType = BucketType.today
domain_id: uuid.UUID | None = None
parent_id: uuid.UUID | None = None
notes: str | None = None
skip_triage_stamp: bool = False
important: bool = False
urgent: bool = False


class TaskUpdate(BaseModel):
Expand All @@ -21,6 +28,8 @@ class TaskUpdate(BaseModel):
domain_id: uuid.UUID | None = None
status: TaskStatus | None = None
notes: str | None = None
important: bool | None = None
urgent: bool | None = None


class ReorderRequest(BaseModel):
Expand Down Expand Up @@ -64,6 +73,8 @@ class TaskResponse(BaseModel):
updated_at: datetime
completed_at: datetime | None
is_mit: bool = False
important: bool = False
urgent: bool = False

@computed_field
@property
Expand Down
10 changes: 10 additions & 0 deletions backend/app/services/task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def create_task(
parent_id: uuid.UUID | None = None,
notes: str | None = None,
skip_triage_stamp: bool = False,
important: bool = False,
urgent: bool = False,
) -> Task:
if len(text) > 500:
raise AppError(
Expand Down Expand Up @@ -101,6 +103,8 @@ def create_task(
notes=notes,
position=position,
triaged_at=None if skip_triage_stamp else date.today(),
important=important,
urgent=urgent,
)
db.add(task)
db.flush()
Expand Down Expand Up @@ -160,6 +164,8 @@ def update_task(
domain_id: uuid.UUID | None | object = _UNSET,
status: TaskStatus | None = None,
notes: str | None | object = _UNSET,
important: bool | None = None,
urgent: bool | None = None,
) -> Task:
task = get_task(db, user_id, task_id)

Expand Down Expand Up @@ -195,6 +201,10 @@ def update_task(
task.bucket = bucket
if domain_id is not _UNSET:
task.domain_id = domain_id
if important is not None:
task.important = important
if urgent is not None:
task.urgent = urgent
if status is not None:
allowed = {
TaskStatus.pending: {TaskStatus.archived},
Expand Down
127 changes: 127 additions & 0 deletions backend/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,130 @@ def test_notes_preserved_on_unrelated_update(self, client, test_user, test_tasks
r = client.patch(f"/tasks/{task.id}", json={"text": "New text"})
assert r.status_code == 200
assert r.json()["notes"] == "Keep this"


class TestPriorityFields:
def test_create_task_with_priority(self, client, test_user):
r = client.post("/tasks", json={"text": "Q1 task", "important": True, "urgent": True})
assert r.status_code == 201
data = r.json()
assert data["important"] is True
assert data["urgent"] is True

def test_create_task_default_priority(self, client, test_user):
r = client.post("/tasks", json={"text": "Plain task"})
assert r.status_code == 201
data = r.json()
assert data["important"] is False
assert data["urgent"] is False

def test_patch_important(self, client, test_user, test_tasks):
task = test_tasks[0]
r = client.patch(f"/tasks/{task.id}", json={"important": True})
assert r.status_code == 200
assert r.json()["important"] is True
assert r.json()["urgent"] is False # unchanged

def test_priority_endpoint_toggle(self, client, test_user, test_tasks):
task = test_tasks[0]
r = client.post(f"/tasks/{task.id}/priority", json={"important": True})
assert r.status_code == 200
assert r.json()["important"] is True
assert r.json()["urgent"] is False

def test_priority_endpoint_partial_update(self, client, test_user, test_tasks):
"""Partial payload doesn't reset the other field."""
task = test_tasks[0]
client.post(f"/tasks/{task.id}/priority", json={"important": True, "urgent": True})
r = client.post(f"/tasks/{task.id}/priority", json={"urgent": False})
assert r.status_code == 200
assert r.json()["important"] is True # untouched
assert r.json()["urgent"] is False

def test_priority_endpoint_idempotent(self, client, test_user, test_tasks):
task = test_tasks[0]
client.post(f"/tasks/{task.id}/priority", json={"important": True})
r = client.post(f"/tasks/{task.id}/priority", json={"important": True})
assert r.status_code == 200
assert r.json()["important"] is True


class TestStateEndpoint:
def test_state_priority_counts(self, client, test_user, db):
# Create tasks in each quadrant
from app.models.task import Task as TaskModel
from app.models.enums import BucketType, TaskStatus
import uuid
user_id = uuid.UUID("00000000-0000-0000-0000-000000000099")

for important, urgent in [(True, True), (True, False), (False, True), (False, False)]:
t = TaskModel(user_id=user_id, text="t", bucket=BucketType.today,
status=TaskStatus.pending, important=important, urgent=urgent)
db.add(t)
db.flush()

r = client.get("/state")
assert r.status_code == 200
p = r.json()["priority"]
assert p["q1_count"] == 1
assert p["q2_count"] == 1
assert p["q3_count"] == 1
assert p["q4_count"] == 1

def test_state_excludes_completed(self, client, test_user, db):
from app.models.task import Task as TaskModel
from app.models.enums import BucketType, TaskStatus
import uuid
user_id = uuid.UUID("00000000-0000-0000-0000-000000000099")

t = TaskModel(user_id=user_id, text="done", bucket=BucketType.today,
status=TaskStatus.complete, important=True, urgent=True)
db.add(t)
db.flush()

r = client.get("/state")
assert r.status_code == 200
assert r.json()["priority"]["q1_count"] == 0

def test_state_excludes_subtasks(self, client, test_user, db):
"""Sub-tasks with priority flags must not appear in quadrant counts."""
from app.models.task import Task as TaskModel
from app.models.enums import BucketType, TaskStatus
import uuid
user_id = uuid.UUID("00000000-0000-0000-0000-000000000099")

parent = TaskModel(user_id=user_id, text="parent", bucket=BucketType.today,
status=TaskStatus.pending)
db.add(parent)
db.flush()

child = TaskModel(user_id=user_id, text="child", bucket=BucketType.today,
status=TaskStatus.pending, parent_id=parent.id,
important=True, urgent=True)
db.add(child)
db.flush()

r = client.get("/state")
assert r.status_code == 200
# Sub-task must not appear in q1_count
assert r.json()["priority"]["q1_count"] == 0

def test_state_scoped_to_current_user(self, client, test_user, db):
"""Tasks from another user must not appear in counts."""
from app.models.task import Task as TaskModel
from app.models.user import User
from app.models.enums import BucketType, TaskStatus, AuthProvider
import uuid

other_id = uuid.uuid4()
other = User(id=other_id, email="other@tend.app", auth_provider=AuthProvider.email)
db.add(other)

t = TaskModel(user_id=other_id, text="other", bucket=BucketType.today,
status=TaskStatus.pending, important=True, urgent=True)
db.add(t)
db.flush()

r = client.get("/state")
assert r.status_code == 200
assert r.json()["priority"]["q1_count"] == 0
Loading