feat: improve app ui and project metadata

This commit is contained in:
Yaojia Wang
2026-06-28 07:31:20 +02:00
parent 3bd464d400
commit 90a66437d7
86 changed files with 4892 additions and 1108 deletions

View File

@@ -0,0 +1,19 @@
from datetime import UTC, datetime
from ww_core.domain.project_repo import max_time
def test_max_time_handles_missing_values() -> None:
now = datetime(2026, 6, 28, tzinfo=UTC)
assert max_time(None, None) is None
assert max_time(now, None) == now
assert max_time(None, now) == now
def test_max_time_returns_latest_value() -> None:
early = datetime(2026, 6, 27, tzinfo=UTC)
late = datetime(2026, 6, 28, tzinfo=UTC)
assert max_time(early, late) == late
assert max_time(late, early) == late

View File

@@ -9,11 +9,12 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Protocol
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Project
from ww_db.models import Chapter, ChapterReview, Project
@dataclass(frozen=True)
@@ -28,6 +29,8 @@ class ProjectView:
theme: str | None = None
selling_points: list[Any] = field(default_factory=list)
structure: str | None = None
updated_at: datetime | None = None
pending_review_count: int = 0
@dataclass(frozen=True)
@@ -53,7 +56,12 @@ class ProjectRepo(Protocol):
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: ...
def _to_view(row: Project) -> ProjectView:
def _to_view(
row: Project,
*,
updated_at: datetime | None = None,
pending_review_count: int = 0,
) -> ProjectView:
return ProjectView(
id=row.id,
title=row.title,
@@ -63,6 +71,8 @@ def _to_view(row: Project) -> ProjectView:
theme=row.theme,
selling_points=list(row.selling_points or []),
structure=row.structure,
updated_at=updated_at or row.updated_at,
pending_review_count=pending_review_count,
)
@@ -89,12 +99,26 @@ class SqlProjectRepo:
return _to_view(row)
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
rows = (
await self._s.execute(
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
projects = (
(
await self._s.execute(
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
)
)
).scalars()
return [_to_view(r) for r in rows]
.scalars()
.all()
)
project_ids = [p.id for p in projects]
pending_counts = await self._pending_review_counts(project_ids)
activity_times = await self._activity_times(project_ids)
return [
_to_view(
p,
updated_at=max_time(p.updated_at, activity_times.get(p.id)),
pending_review_count=pending_counts.get(p.id, 0),
)
for p in projects
]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
row = (
@@ -104,4 +128,90 @@ class SqlProjectRepo:
).scalar_one_or_none()
if row is None:
return None
return _to_view(row)
pending_counts = await self._pending_review_counts([project_id])
activity_times = await self._activity_times([project_id])
return _to_view(
row,
updated_at=max_time(row.updated_at, activity_times.get(project_id)),
pending_review_count=pending_counts.get(project_id, 0),
)
async def _activity_times(self, project_ids: list[uuid.UUID]) -> dict[uuid.UUID, datetime]:
if not project_ids:
return {}
chapter_rows = await self._s.execute(
select(Chapter.project_id, func.max(Chapter.updated_at))
.where(Chapter.project_id.in_(project_ids))
.group_by(Chapter.project_id)
)
review_rows = await self._s.execute(
select(ChapterReview.project_id, func.max(ChapterReview.created_at))
.where(ChapterReview.project_id.in_(project_ids))
.group_by(ChapterReview.project_id)
)
activity: dict[uuid.UUID, datetime] = {}
for project_id, updated_at in [*chapter_rows.all(), *review_rows.all()]:
if updated_at is None:
continue
latest = max_time(activity.get(project_id), updated_at)
if latest is not None:
activity[project_id] = latest
return activity
async def _pending_review_counts(self, project_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not project_ids:
return {}
latest_reviews = (
select(
ChapterReview.project_id.label("project_id"),
ChapterReview.chapter_no.label("chapter_no"),
func.max(ChapterReview.created_at).label("reviewed_at"),
)
.where(ChapterReview.project_id.in_(project_ids))
.group_by(ChapterReview.project_id, ChapterReview.chapter_no)
.subquery()
)
latest_accepted = (
select(
Chapter.project_id.label("project_id"),
Chapter.chapter_no.label("chapter_no"),
func.max(Chapter.updated_at).label("accepted_at"),
)
.where(Chapter.project_id.in_(project_ids), Chapter.status == "accepted")
.group_by(Chapter.project_id, Chapter.chapter_no)
.subquery()
)
stmt = (
select(Chapter.project_id, func.count(Chapter.id))
.outerjoin(
latest_reviews,
(latest_reviews.c.project_id == Chapter.project_id)
& (latest_reviews.c.chapter_no == Chapter.chapter_no),
)
.outerjoin(
latest_accepted,
(latest_accepted.c.project_id == Chapter.project_id)
& (latest_accepted.c.chapter_no == Chapter.chapter_no),
)
.where(
Chapter.project_id.in_(project_ids),
Chapter.status == "draft",
(latest_accepted.c.accepted_at.is_(None))
| (latest_accepted.c.accepted_at < Chapter.updated_at),
(latest_reviews.c.reviewed_at.is_(None))
| (latest_reviews.c.reviewed_at < Chapter.updated_at),
)
.group_by(Chapter.project_id)
)
rows = await self._s.execute(stmt)
return {project_id: int(count) for project_id, count in rows.all()}
def max_time(left: datetime | None, right: datetime | None) -> datetime | None:
if left is None:
return right
if right is None:
return left
return max(left, right)