From 7002b23eebe1f6e66c0b1106dbbcba091f240bf1 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 4 Jul 2026 20:24:59 +0000 Subject: [PATCH] feat: add clickable activity cards with comment anchor links to profile tab Add `url` field to profile activity items so post cards link to `/posts/{slug}` and comment cards link to the parent post with a `#comment-{uid}` anchor, matching notification click behavior. Include the shared `_card_link.html` overlay in the template, fix XP bar rendering for missing `xp` key, and add API + e2e tests verifying the link structure and navigation. --- AGENTS.md | 24 +++++++ devplacepy/routers/profile/index.py | 3 + devplacepy/templates/profile.html | 11 ++- tests/api/profile/index.py | 97 +++++++++++++++++++++++++++ tests/conftest.py | 2 +- tests/e2e/admin/users/aiusage.py | 5 +- tests/e2e/game/index.py | 2 +- tests/e2e/profile/index.py | 66 ++++++++++++++++++ tests/e2e/profile/regenerateavatar.py | 10 +-- tests/unit/services/game/economy.py | 4 +- tests/unit/services/game/store.py | 2 + 11 files changed, 213 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 99168599..b8dea646 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -510,6 +510,30 @@ code stays tiny. Reach for these instead of hand-rolling a loop, a fetch, or a c `window.innerWidth` change closes it. All paths fall back to `window.inner*` when `visualViewport` is absent, so desktop and old browsers are untouched. +## Profile activity tab (clickable comments) + +The profile **Activity** tab (`/profile/{username}?tab=activity`, public) interleaves the user's 10 +most recent posts and 10 most recent comments, newest first. Each activity item is a plain dict +built inline in `routers/profile/index.py` (no dedicated DB helper) and carries a `url` so the whole +card is clickable, exactly like the feed's post cards: + +- **Post items** get `"url": resolve_object_url("post", p["uid"])` -> `/posts/{slug}`. +- **Comment items** get `"url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}"`, + the SEO-friendly parent detail URL plus the `#comment-{uid}` anchor. This is **byte-identical to a + comment notification's `target_url`** (`content.create_comment_record`), so clicking an activity + comment reproduces the notification click exactly: it lands on the parent post and + `NotificationManager.js` scrolls to / highlights the `id="comment-{uid}"` element + (`_comment.html`). Reuse `resolve_object_url` (`database.py`) - never rebuild this URL by hand, and + do not call `resolve_object_url("comment", uid)` here (it re-fetches the row the loop already has). +- **Frontend:** the card is wrapped in the shared full-card overlay link (`card-link-host` + + `_card_link.html`), the same pattern `_post_card.html` uses; inner content links (mentions, URLs + rendered by `render_content`) stay clickable via the existing `card-link-host a:not(.card-link)` + z-index rule (`base.css`). No new CSS. +- **API:** `ProfileOut.activities` is `list[Any]`, so the added `url` (and, on comments, the existing + `target_type` + `uid` = parent target uid) flow into the JSON response with no schema change - this + is the parent-post reference exposed on each comment item. Covered by `tests/api/profile/index.py` + (activity `url` assertions) and `tests/e2e/profile/index.py` (click-through). + ## Profile media gallery and soft-deleted attachments The profile **Media** tab (`/profile/{username}?tab=media`, public) is a paginated grid of every diff --git a/devplacepy/routers/profile/index.py b/devplacepy/routers/profile/index.py index 12ed8ec0..fd703a55 100644 --- a/devplacepy/routers/profile/index.py +++ b/devplacepy/routers/profile/index.py @@ -26,6 +26,7 @@ from devplacepy.database import ( get_user_media, search_users_by_username, mark_notifications_read_by_target, + resolve_object_url, ) from devplacepy.content import can_view_project, enrich_items from devplacepy.utils import ( @@ -209,6 +210,7 @@ async def profile_page( "time_ago": time_ago(p["created_at"]), "created_at": p["created_at"], "uid": p["uid"], + "url": resolve_object_url("post", p["uid"]), } ) comments_table = get_table("comments") @@ -228,6 +230,7 @@ async def profile_page( "created_at": c["created_at"], "uid": target_uid, "target_type": target_type, + "url": f"{resolve_object_url(target_type, target_uid)}#comment-{c['uid']}", } ) activities.sort(key=lambda a: a["created_at"], reverse=True) diff --git a/devplacepy/templates/profile.html b/devplacepy/templates/profile.html index edea800c..2eaa7740 100644 --- a/devplacepy/templates/profile.html +++ b/devplacepy/templates/profile.html @@ -39,10 +39,10 @@
Progress to next level - {{ profile_user.get('xp', 0) % 100 }}% + {{ (profile_user.get('xp') or 0) % 100 }}%
-
+
@@ -637,7 +637,12 @@ {% endif %} {% else %} {% for act in activities %} -
+
+ {% if act['url'] %} + {% set _href = act['url'] %} + {% set _label = (act['type'] == 'post') and 'View post' or 'View comment on post' %} + {% include "_card_link.html" %} + {% endif %}
{% if act['type'] == 'post' %}📝{% else %}💬{% endif %} diff --git a/tests/api/profile/index.py b/tests/api/profile/index.py index 790ac614..8c9bb522 100644 --- a/tests/api/profile/index.py +++ b/tests/api/profile/index.py @@ -170,6 +170,103 @@ def test_missing_profile_returns_404(app_server): assert r.status_code == 404 +def _seed_comment_on_post(commenter_uid): + from datetime import datetime, timezone + from uuid import uuid4 + from devplacepy.database import get_table + + post_slug, post_uid = _seed_post_seo() + comment_uid = str(uuid4()) + get_table("comments").insert( + { + "deleted_at": None, + "deleted_by": None, + "uid": comment_uid, + "target_type": "post", + "target_uid": post_uid, + "post_uid": post_uid, + "user_uid": commenter_uid, + "content": "Activity tab link comment body", + "parent_uid": None, + "created_at": datetime.now(timezone.utc).isoformat(), + } + ) + return post_slug, post_uid, comment_uid + + +def test_activity_comment_exposes_parent_post_url(app_server): + from devplacepy.database import get_table, refresh_snapshot + + commenter = _seed_owner() + username = get_table("users").find_one(uid=commenter)["username"] + post_slug, _post_uid, comment_uid = _seed_comment_on_post(commenter) + refresh_snapshot() + + r = requests.get( + f"{BASE_URL}/profile/{username}?tab=activity", + headers={"Accept": "application/json"}, + ) + assert r.status_code == 200 + activities = r.json()["activities"] + comment_acts = [a for a in activities if a.get("type") == "comment"] + assert comment_acts, "expected a comment activity" + act = comment_acts[0] + assert act["url"] == f"/posts/{post_slug}#comment-{comment_uid}" + assert act["target_type"] == "post" + + +def test_activity_comment_url_matches_notification_target(app_server): + from devplacepy.database import get_table, refresh_snapshot, resolve_object_url + + commenter = _seed_owner() + username = get_table("users").find_one(uid=commenter)["username"] + _post_slug, post_uid, comment_uid = _seed_comment_on_post(commenter) + refresh_snapshot() + + expected = f"{resolve_object_url('post', post_uid)}#comment-{comment_uid}" + + r = requests.get( + f"{BASE_URL}/profile/{username}?tab=activity", + headers={"Accept": "application/json"}, + ) + act = [a for a in r.json()["activities"] if a.get("type") == "comment"][0] + assert act["url"] == expected + + +def test_activity_post_exposes_url(app_server): + from datetime import datetime, timezone + from devplacepy.database import get_table, refresh_snapshot + + owner = _seed_owner() + username = get_table("users").find_one(uid=owner)["username"] + post_slug, post_uid = _seed_post_seo() + get_table("posts").update({"uid": post_uid, "user_uid": owner}, ["uid"]) + refresh_snapshot() + + r = requests.get( + f"{BASE_URL}/profile/{username}?tab=activity", + headers={"Accept": "application/json"}, + ) + assert r.status_code == 200 + post_acts = [a for a in r.json()["activities"] if a.get("type") == "post"] + assert post_acts, "expected a post activity" + assert post_acts[0]["url"] == f"/posts/{post_slug}" + + +def test_activity_comment_card_renders_overlay_link(app_server): + from devplacepy.database import get_table, refresh_snapshot + + commenter = _seed_owner() + username = get_table("users").find_one(uid=commenter)["username"] + post_slug, _post_uid, comment_uid = _seed_comment_on_post(commenter) + refresh_snapshot() + + r = requests.get(f"{BASE_URL}/profile/{username}?tab=activity") + assert r.status_code == 200 + assert "activity-card card-link-host" in r.text + assert f'class="card-link" href="/posts/{post_slug}#comment-{comment_uid}"' in r.text + + def test_viewing_profile_marks_notification_read(app_server): import time from datetime import datetime, timezone diff --git a/tests/conftest.py b/tests/conftest.py index 2321d955..d4e34a95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent SCREENSHOT_DIR = Path("/tmp/devplace_test_screenshots") -PORT = 10501 +PORT = int(os.environ.get("DEVPLACE_TEST_PORT", "10501")) BASE_URL = f"http://127.0.0.1:{PORT}" _TEST_DB = tempfile.NamedTemporaryFile(suffix=f"_{PORT}.db", delete=False) diff --git a/tests/e2e/admin/users/aiusage.py b/tests/e2e/admin/users/aiusage.py index 46210597..ce9f6e48 100644 --- a/tests/e2e/admin/users/aiusage.py +++ b/tests/e2e/admin/users/aiusage.py @@ -93,5 +93,8 @@ def test_admin_user_ai_usage_endpoint_returns_data(alice, seeded_db): def test_member_forbidden_from_user_ai_usage_endpoint(bob, seeded_db): page, _ = bob bob_uid = get_table("users").find_one(username=seeded_db["bob"]["username"])["uid"] - resp = page.request.get(f"{BASE_URL}/admin/users/{bob_uid}/ai-usage") + resp = page.request.get( + f"{BASE_URL}/admin/users/{bob_uid}/ai-usage", + headers={"Accept": "application/json"}, + ) assert resp.status == 403 diff --git a/tests/e2e/game/index.py b/tests/e2e/game/index.py index 1829418e..e7e0f0bc 100644 --- a/tests/e2e/game/index.py +++ b/tests/e2e/game/index.py @@ -63,7 +63,7 @@ def test_game_page_loads(alice): assert page.locator(".game-header h1").is_visible() expect(page.locator("[data-hud-coins]")).to_have_text("100000") expect(page.locator("[data-game-grid] [data-slot]")).to_have_count(4) - expect(page.locator(".perk-card")).to_have_count(4) + expect(page.locator(".perk-card:not(.legacy-card)")).to_have_count(4) expect(page.locator(".quest-item")).to_have_count(3) diff --git a/tests/e2e/profile/index.py b/tests/e2e/profile/index.py index 90409b7a..07ac41f5 100644 --- a/tests/e2e/profile/index.py +++ b/tests/e2e/profile/index.py @@ -4,6 +4,72 @@ from tests.conftest import BASE_URL FOLLOW = "form[action='/follow/bob_test'] button" UNFOLLOW = "form[action='/follow/unfollow/bob_test'] button" from playwright.sync_api import expect +from tests.e2e.post import create_post + + +def _post_comment(page, body): + textarea = page.locator(".comment-form textarea[name='content']") + textarea.fill(body) + page.locator(".comment-form button:has-text('Post')").click() + expect(page.locator(f".comment-text:has-text('{body}')")).to_be_visible() + + +def test_activity_comment_card_links_to_parent_post(alice): + page, user = alice + create_post(page, "devlog", "Post for activity comment link") + post_url = page.url + _post_comment(page, "Activity clickable comment body") + + page.goto( + f"{BASE_URL}/profile/{user['username']}?tab=activity", + wait_until="domcontentloaded", + ) + card_link = page.locator( + ".activity-card.card-link-host a.card-link[href*='#comment-']" + ).first + expect(card_link).to_have_count(1) + href = card_link.get_attribute("href") + assert "/posts/" in href and "#comment-" in href + + card_link.click() + page.wait_for_url("**/posts/**", wait_until="domcontentloaded") + expect( + page.locator(".comment-text:has-text('Activity clickable comment body')") + ).to_be_visible() + + +def test_activity_post_card_links_to_post(alice): + page, user = alice + create_post(page, "showcase", "Post for activity post link", title="Activity Post Title") + + page.goto( + f"{BASE_URL}/profile/{user['username']}?tab=activity", + wait_until="domcontentloaded", + ) + post_link = page.locator( + ".activity-card.card-link-host a.card-link[href^='/posts/']:not([href*='#comment-'])" + ).first + expect(post_link).to_have_count(1) + post_link.click() + page.wait_for_url("**/posts/**", wait_until="domcontentloaded") + expect(page.locator(".post-detail-title:has-text('Activity Post Title')")).to_be_visible() + + +def test_activity_inner_content_link_stays_clickable(alice, bob): + page, user = alice + create_post(page, "random", "Post for activity mention comment") + _post_comment(page, "Ping @bob_test in activity") + + page.goto( + f"{BASE_URL}/profile/{user['username']}?tab=activity", + wait_until="domcontentloaded", + ) + mention = page.locator( + ".activity-card.card-link-host .activity-content a[href='/profile/bob_test']" + ).first + expect(mention).to_have_count(1) + mention.click() + page.wait_for_url("**/profile/bob_test", wait_until="domcontentloaded") def test_follow_then_unfollow(alice): diff --git a/tests/e2e/profile/regenerateavatar.py b/tests/e2e/profile/regenerateavatar.py index 547165e4..a6b6a480 100644 --- a/tests/e2e/profile/regenerateavatar.py +++ b/tests/e2e/profile/regenerateavatar.py @@ -22,9 +22,9 @@ def test_regenerate_avatar_updates_preview(alice): def test_regenerate_button_hidden_for_other_user(alice, bob): - page, _ = alice - bob_page, bob_user = bob - page.goto( - f"{BASE_URL}/profile/{bob_user['username']}", wait_until="domcontentloaded" + alice_page, alice_user = alice + bob_page, _ = bob + bob_page.goto( + f"{BASE_URL}/profile/{alice_user['username']}", wait_until="domcontentloaded" ) - expect(page.locator("[data-regenerate-avatar]")).to_have_count(0) + expect(bob_page.locator("[data-regenerate-avatar]")).to_have_count(0) diff --git a/tests/unit/services/game/economy.py b/tests/unit/services/game/economy.py index e92a38d8..81345659 100644 --- a/tests/unit/services/game/economy.py +++ b/tests/unit/services/game/economy.py @@ -72,8 +72,8 @@ def test_prestige_multiplier(): def test_fertilize_cost_has_floor(): - assert economy.fertilize_cost(1) == economy.FERTILIZE_MIN_COST - assert economy.fertilize_cost(10000) > economy.FERTILIZE_MIN_COST + assert economy.fertilize_click_cost(1, 1, 1_000_000) == 1 + assert economy.fertilize_click_cost(10_000, 50, 100) > 1 def test_crop_payload_locked_flag(): diff --git a/tests/unit/services/game/store.py b/tests/unit/services/game/store.py index 60700ff0..311a6d8b 100644 --- a/tests/unit/services/game/store.py +++ b/tests/unit/services/game/store.py @@ -32,6 +32,8 @@ def _reset(username, coins=100000): get_table("game_plots").delete(farm_uid=farm["uid"]) get_table("game_quests").delete(farm_uid=farm["uid"]) get_table("game_farms").delete(uid=farm["uid"]) + get_table("game_steals").delete(thief_uid=user["uid"]) + get_table("game_steals").delete(owner_uid=user["uid"]) farm = store.ensure_farm(user["uid"]) get_table("game_farms").update({"uid": farm["uid"], "coins": coins}, ["uid"]) return user