From 961c9bd37cb8451a34004afa8627720a507e0b10 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Mon, 8 Jun 2026 16:25:32 +0800
Subject: [PATCH] Show feedback reward details in account
---
...26-06-08-feedback-reward-account-detail.md | 345 ++++++++++++++++++
.../account/AccountFeedbackPanel.tsx | 54 +++
.../__tests__/accountFeedbackPanel.test.ts | 9 +
frontend/types/ops.ts | 4 +
src/database/db_manager.py | 69 +++-
tests/test_user_feedback.py | 40 ++
6 files changed, 518 insertions(+), 3 deletions(-)
create mode 100644 docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md
diff --git a/docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md b/docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md
new file mode 100644
index 00000000..a34be87d
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md
@@ -0,0 +1,345 @@
+# Feedback Reward Account Detail Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let users see which submitted feedback earned points, how many points were awarded, and the reward reason in the account page.
+
+**Architecture:** Store reward metadata directly on `user_feedback` rows so the existing feedback list API can expose the reward source without a separate ledger join. The account feedback panel renders reward state inline with each feedback item and stays silent for rows with no reward metadata.
+
+**Tech Stack:** Python SQLite `DBManager`, FastAPI feedback service, Next.js/React account components, TypeScript source-based business tests.
+
+---
+
+## File Structure
+
+- Modify `src/database/db_manager.py`: add reward columns, serialize them, and add `update_user_feedback_reward()` for future ops reward workflows.
+- Modify `tests/test_user_feedback.py`: add backend coverage for reward defaults and reward metadata round trip.
+- Modify `frontend/types/ops.ts`: add optional reward fields to `UserFeedbackEntry`.
+- Modify `frontend/components/account/AccountFeedbackPanel.tsx`: render reward labels and reasons inside each feedback row.
+- Modify `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`: assert the panel handles reward display states.
+
+---
+
+### Task 1: Backend Feedback Reward Metadata
+
+**Files:**
+- Modify: `tests/test_user_feedback.py`
+- Modify: `src/database/db_manager.py`
+
+- [ ] **Step 1: Write the failing backend test**
+
+Add this test to `tests/test_user_feedback.py`:
+
+```python
+def test_user_feedback_reward_metadata_round_trip(tmp_path):
+ db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
+
+ created = db.append_user_feedback(
+ category="data",
+ message="Hong Kong COWIN reading was stale.",
+ user_id="user-reward",
+ user_email="reward@example.com",
+ )
+
+ assert created["reward_points"] == 0
+ assert created["reward_reason"] == ""
+ assert created["reward_status"] == ""
+ assert created["rewarded_at"] is None
+
+ rewarded = db.update_user_feedback_reward(
+ created["id"],
+ points=300,
+ reason="Valid data freshness report",
+ status="granted",
+ )
+
+ assert rewarded is not None
+ assert rewarded["reward_points"] == 300
+ assert rewarded["reward_reason"] == "Valid data freshness report"
+ assert rewarded["reward_status"] == "granted"
+ assert rewarded["rewarded_at"]
+
+ row = db.list_user_feedback(
+ limit=10,
+ user_id="user-reward",
+ user_email="reward@example.com",
+ )[0]
+ assert row["id"] == created["id"]
+ assert row["reward_points"] == 300
+ assert row["reward_reason"] == "Valid data freshness report"
+ assert row["reward_status"] == "granted"
+ assert row["rewarded_at"] == rewarded["rewarded_at"]
+```
+
+- [ ] **Step 2: Run the backend test and verify it fails**
+
+Run:
+
+```powershell
+python -m pytest tests/test_user_feedback.py::test_user_feedback_reward_metadata_round_trip -q
+```
+
+Expected: failure because `reward_points` is missing or `update_user_feedback_reward` is undefined.
+
+- [ ] **Step 3: Add reward columns and serialization**
+
+In `src/database/db_manager.py`, update `user_feedback` schema and migration:
+
+```python
+reward_points INTEGER DEFAULT 0,
+reward_reason TEXT DEFAULT '',
+rewarded_at TIMESTAMP,
+reward_status TEXT DEFAULT ''
+```
+
+Add `_ensure_column()` calls for the same columns in the existing migration block.
+
+Update every feedback `SELECT` to include:
+
+```sql
+reward_points, reward_reason, rewarded_at, reward_status
+```
+
+Update `_feedback_row_to_dict()` to return:
+
+```python
+"reward_points": max(0, int(row["reward_points"] or 0)),
+"reward_reason": str(row["reward_reason"] or ""),
+"rewarded_at": row["rewarded_at"],
+"reward_status": str(row["reward_status"] or ""),
+```
+
+- [ ] **Step 4: Add reward update method**
+
+Add this method near `update_user_feedback_status()` in `src/database/db_manager.py`:
+
+```python
+def update_user_feedback_reward(
+ self,
+ feedback_id: int,
+ *,
+ points: int,
+ reason: str = "",
+ status: str = "granted",
+) -> Optional[Dict[str, Any]]:
+ safe_points = max(0, int(points or 0))
+ normalized_reason = str(reason or "").strip()[:500]
+ normalized_status = str(status or "").strip().lower()[:40]
+ if not normalized_status:
+ normalized_status = "granted" if safe_points > 0 else "skipped"
+ now = datetime.now().isoformat()
+ with self._get_connection() as conn:
+ conn.row_factory = sqlite3.Row
+ conn.execute(
+ """
+ UPDATE user_feedback
+ SET reward_points = ?,
+ reward_reason = ?,
+ reward_status = ?,
+ rewarded_at = ?,
+ updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ safe_points,
+ normalized_reason,
+ normalized_status,
+ now,
+ now,
+ int(feedback_id),
+ ),
+ )
+ row = conn.execute(
+ """
+ SELECT id, category, message, source, status, contact, user_id,
+ user_email, context_json, reward_points, reward_reason,
+ rewarded_at, reward_status, created_at, updated_at
+ FROM user_feedback
+ WHERE id = ?
+ """,
+ (int(feedback_id),),
+ ).fetchone()
+ conn.commit()
+ return self._feedback_row_to_dict(row) if row else None
+```
+
+- [ ] **Step 5: Run backend tests**
+
+Run:
+
+```powershell
+python -m pytest tests/test_user_feedback.py -q
+```
+
+Expected: all `test_user_feedback.py` tests pass.
+
+---
+
+### Task 2: Account Feedback Reward Display
+
+**Files:**
+- Modify: `frontend/types/ops.ts`
+- Modify: `frontend/components/account/AccountFeedbackPanel.tsx`
+- Modify: `frontend/components/account/__tests__/accountFeedbackPanel.test.ts`
+
+- [ ] **Step 1: Write the failing frontend test**
+
+Extend the final assertion in `frontend/components/account/__tests__/accountFeedbackPanel.test.ts` or add a new assertion:
+
+```ts
+assert(
+ feedbackPanelSource.includes("reward_points") &&
+ feedbackPanelSource.includes("reward_reason") &&
+ feedbackPanelSource.includes("reward_status") &&
+ feedbackPanelSource.includes("formatRewardPoints") &&
+ feedbackPanelSource.includes("renderFeedbackReward") &&
+ feedbackPanelSource.includes("奖励原因"),
+ "account feedback panel must show per-feedback reward points and reward reasons",
+);
+```
+
+- [ ] **Step 2: Run the frontend business test and verify it fails**
+
+Run:
+
+```powershell
+cd frontend; npm run test:business -- accountFeedbackPanel
+```
+
+Expected: failure because the panel does not yet reference the reward fields.
+
+- [ ] **Step 3: Extend frontend type**
+
+Add optional fields to `UserFeedbackEntry` in `frontend/types/ops.ts`:
+
+```ts
+ reward_points?: number;
+ reward_reason?: string;
+ rewarded_at?: string | null;
+ reward_status?: string;
+```
+
+- [ ] **Step 4: Add account reward rendering helpers**
+
+In `AccountFeedbackPanel.tsx`, add helpers:
+
+```tsx
+function formatRewardPoints(points?: number) {
+ const value = Math.max(0, Number(points || 0));
+ return `+${value.toLocaleString()} points`;
+}
+
+function rewardStatusText(status?: string, isEn = false) {
+ const key = String(status || "").toLowerCase();
+ if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
+ if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
+ return isEn ? "Feedback reward" : "反馈奖励";
+}
+
+function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
+ const points = Math.max(0, Number(entry.reward_points || 0));
+ const rewardStatus = String(entry.reward_status || "").toLowerCase();
+ const reason = String(entry.reward_reason || "").trim();
+ if (points <= 0 && !rewardStatus && !reason) return null;
+ const granted = points > 0 || rewardStatus === "granted";
+ return (
+
+
+ {rewardStatusText(rewardStatus, isEn)}
+ {points > 0 ? {formatRewardPoints(points)} : null}
+
+ {reason ? (
+
+ {isEn ? "Reason" : "奖励原因"}: {reason}
+
+ ) : null}
+ {entry.rewarded_at ? (
+
+ {compactDate(entry.rewarded_at)}
+
+ ) : null}
+
+ );
+}
+```
+
+- [ ] **Step 5: Render reward detail in each feedback row**
+
+Inside each feedback row, after the feedback message paragraph, add:
+
+```tsx
+{renderFeedbackReward(entry, isEn)}
+```
+
+- [ ] **Step 6: Run frontend business test**
+
+Run:
+
+```powershell
+cd frontend; npm run test:business -- accountFeedbackPanel
+```
+
+Expected: `accountFeedbackPanel` passes.
+
+---
+
+### Task 3: Verification and Commit
+
+**Files:**
+- Verify all touched files.
+
+- [ ] **Step 1: Run targeted backend verification**
+
+Run:
+
+```powershell
+python -m pytest tests/test_user_feedback.py -q
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 2: Run targeted frontend verification**
+
+Run:
+
+```powershell
+cd frontend; npm run test:business -- accountFeedbackPanel
+```
+
+Expected: `accountFeedbackPanel` test passes.
+
+- [ ] **Step 3: Run broader low-cost verification**
+
+Run:
+
+```powershell
+python -m ruff check .
+cd frontend; npm run typecheck
+```
+
+Expected: both commands pass.
+
+- [ ] **Step 4: Check status and commit**
+
+Run:
+
+```powershell
+git status --short
+git diff --check
+git add docs/superpowers/plans/2026-06-08-feedback-reward-account-detail.md tests/test_user_feedback.py src/database/db_manager.py frontend/types/ops.ts frontend/components/account/AccountFeedbackPanel.tsx frontend/components/account/__tests__/accountFeedbackPanel.test.ts
+git commit -m "Show feedback reward details in account"
+```
+
+Expected: commit succeeds with only planned files staged.
+
+---
+
+## Self-Review
+
+- Spec coverage: reward metadata fields, existing API payload, account inline display, missing metadata fallback, and future ops reuse are covered.
+- Scope control: the plan does not implement automatic reward issuance or external notifications.
+- Type consistency: frontend fields use `reward_points`, `reward_reason`, `rewarded_at`, and `reward_status`, matching backend serialization.
diff --git a/frontend/components/account/AccountFeedbackPanel.tsx b/frontend/components/account/AccountFeedbackPanel.tsx
index b6418f77..fd572945 100644
--- a/frontend/components/account/AccountFeedbackPanel.tsx
+++ b/frontend/components/account/AccountFeedbackPanel.tsx
@@ -23,6 +23,59 @@ function categoryLabel(value?: string, isEn = false) {
return isEn ? "Other" : "其他";
}
+function formatRewardPoints(points?: number, isEn = false) {
+ const raw = Number(points || 0);
+ const value = Number.isFinite(raw) ? Math.max(0, raw) : 0;
+ return isEn
+ ? `+${value.toLocaleString()} points`
+ : `+${value.toLocaleString()} 积分`;
+}
+
+function rewardStatusText(status?: string, isEn = false) {
+ const key = String(status || "").toLowerCase();
+ if (key === "pending") return isEn ? "Reward pending" : "奖励待处理";
+ if (key === "skipped") return isEn ? "No points awarded" : "未发放积分";
+ return isEn ? "Feedback reward" : "反馈奖励";
+}
+
+function renderFeedbackReward(entry: UserFeedbackEntry, isEn: boolean) {
+ const rawPoints = Number(entry.reward_points || 0);
+ const points = Number.isFinite(rawPoints) ? Math.max(0, rawPoints) : 0;
+ const rewardStatus = String(entry.reward_status || "").toLowerCase();
+ const reason = String(entry.reward_reason || "").trim();
+ if (points <= 0 && !rewardStatus && !reason) return null;
+
+ const granted = points > 0 || rewardStatus === "granted";
+ return (
+
+
+ {rewardStatusText(rewardStatus, isEn)}
+ {points > 0 ? (
+
+ {formatRewardPoints(points, isEn)}
+
+ ) : null}
+
+ {reason ? (
+
+ {isEn ? "Reason" : "奖励原因"}: {reason}
+
+ ) : null}
+ {entry.rewarded_at ? (
+
+ {compactDate(entry.rewarded_at)}
+
+ ) : null}
+
+ );
+}
+
export function AccountFeedbackPanel({
isEn,
title,
@@ -129,6 +182,7 @@ export function AccountFeedbackPanel({
{entry.message || (isEn ? "Feedback" : "反馈")}
+ {renderFeedbackReward(entry, isEn)}
{compactDate(entry.updated_at)}
diff --git a/frontend/components/account/__tests__/accountFeedbackPanel.test.ts b/frontend/components/account/__tests__/accountFeedbackPanel.test.ts
index 8dcd53ee..aba6425b 100644
--- a/frontend/components/account/__tests__/accountFeedbackPanel.test.ts
+++ b/frontend/components/account/__tests__/accountFeedbackPanel.test.ts
@@ -31,4 +31,13 @@ export function runTests() {
!feedbackPanelSource.includes("setInterval"),
"account feedback panel must load the current user's feedback once, support manual refresh, and avoid polling",
);
+ assert(
+ feedbackPanelSource.includes("reward_points") &&
+ feedbackPanelSource.includes("reward_reason") &&
+ feedbackPanelSource.includes("reward_status") &&
+ feedbackPanelSource.includes("formatRewardPoints") &&
+ feedbackPanelSource.includes("renderFeedbackReward") &&
+ feedbackPanelSource.includes("奖励原因"),
+ "account feedback panel must show per-feedback reward points and reward reasons",
+ );
}
diff --git a/frontend/types/ops.ts b/frontend/types/ops.ts
index 2b1ba005..9a67e8de 100644
--- a/frontend/types/ops.ts
+++ b/frontend/types/ops.ts
@@ -197,6 +197,10 @@ export type UserFeedbackEntry = {
user_id?: string;
user_email?: string;
context?: Record
;
+ reward_points?: number;
+ reward_reason?: string;
+ rewarded_at?: string | null;
+ reward_status?: string;
created_at?: string;
updated_at?: string;
};
diff --git a/src/database/db_manager.py b/src/database/db_manager.py
index 85b2633c..bf36d2a1 100644
--- a/src/database/db_manager.py
+++ b/src/database/db_manager.py
@@ -562,6 +562,10 @@ class DBManager:
user_id TEXT,
user_email TEXT,
context_json TEXT NOT NULL,
+ reward_points INTEGER DEFAULT 0,
+ reward_reason TEXT DEFAULT '',
+ rewarded_at TIMESTAMP,
+ reward_status TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -667,6 +671,10 @@ class DBManager:
self._ensure_column(conn, "users", "daily_city_queries", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_deb_queries", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_queries_date", "TEXT")
+ self._ensure_column(conn, "user_feedback", "reward_points", "INTEGER DEFAULT 0")
+ self._ensure_column(conn, "user_feedback", "reward_reason", "TEXT DEFAULT ''")
+ self._ensure_column(conn, "user_feedback", "rewarded_at", "TIMESTAMP")
+ self._ensure_column(conn, "user_feedback", "reward_status", "TEXT DEFAULT ''")
# Migrate legacy one-to-one binding column into mapping table.
conn.execute(
"""
@@ -1094,6 +1102,10 @@ class DBManager:
"user_id": str(row["user_id"] or ""),
"user_email": str(row["user_email"] or ""),
"context": context if isinstance(context, dict) else {},
+ "reward_points": max(0, int(row["reward_points"] or 0)),
+ "reward_reason": str(row["reward_reason"] or ""),
+ "rewarded_at": row["rewarded_at"],
+ "reward_status": str(row["reward_status"] or ""),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
@@ -1151,7 +1163,8 @@ class DBManager:
row = conn.execute(
"""
SELECT id, category, message, source, status, contact, user_id,
- user_email, context_json, created_at, updated_at
+ user_email, context_json, reward_points, reward_reason,
+ rewarded_at, reward_status, created_at, updated_at
FROM user_feedback
WHERE id = ?
""",
@@ -1193,7 +1206,8 @@ class DBManager:
rows = conn.execute(
f"""
SELECT id, category, message, source, status, contact, user_id,
- user_email, context_json, created_at, updated_at
+ user_email, context_json, reward_points, reward_reason,
+ rewarded_at, reward_status, created_at, updated_at
FROM user_feedback
{where_sql}
ORDER BY id DESC
@@ -1226,7 +1240,56 @@ class DBManager:
row = conn.execute(
"""
SELECT id, category, message, source, status, contact, user_id,
- user_email, context_json, created_at, updated_at
+ user_email, context_json, reward_points, reward_reason,
+ rewarded_at, reward_status, created_at, updated_at
+ FROM user_feedback
+ WHERE id = ?
+ """,
+ (int(feedback_id),),
+ ).fetchone()
+ conn.commit()
+ return self._feedback_row_to_dict(row) if row else None
+
+ def update_user_feedback_reward(
+ self,
+ feedback_id: int,
+ *,
+ points: int,
+ reason: str = "",
+ status: str = "granted",
+ ) -> Optional[Dict[str, Any]]:
+ safe_points = max(0, int(points or 0))
+ normalized_reason = str(reason or "").strip()[:500]
+ normalized_status = str(status or "").strip().lower()[:40]
+ if not normalized_status:
+ normalized_status = "granted" if safe_points > 0 else "skipped"
+ now = datetime.now().isoformat()
+ with self._get_connection() as conn:
+ conn.row_factory = sqlite3.Row
+ conn.execute(
+ """
+ UPDATE user_feedback
+ SET reward_points = ?,
+ reward_reason = ?,
+ reward_status = ?,
+ rewarded_at = ?,
+ updated_at = ?
+ WHERE id = ?
+ """,
+ (
+ safe_points,
+ normalized_reason,
+ normalized_status,
+ now,
+ now,
+ int(feedback_id),
+ ),
+ )
+ row = conn.execute(
+ """
+ SELECT id, category, message, source, status, contact, user_id,
+ user_email, context_json, reward_points, reward_reason,
+ rewarded_at, reward_status, created_at, updated_at
FROM user_feedback
WHERE id = ?
""",
diff --git a/tests/test_user_feedback.py b/tests/test_user_feedback.py
index 0ed99da4..1fae2fc1 100644
--- a/tests/test_user_feedback.py
+++ b/tests/test_user_feedback.py
@@ -53,6 +53,46 @@ def test_user_feedback_status_filter_excludes_other_statuses(tmp_path):
assert open_rows[0]["message"] == "Add a dark chart grid."
+def test_user_feedback_reward_metadata_round_trip(tmp_path):
+ db = DBManager(str(tmp_path / "polyweather-feedback-reward.db"))
+
+ created = db.append_user_feedback(
+ category="data",
+ message="Hong Kong COWIN reading was stale.",
+ user_id="user-reward",
+ user_email="reward@example.com",
+ )
+
+ assert created["reward_points"] == 0
+ assert created["reward_reason"] == ""
+ assert created["reward_status"] == ""
+ assert created["rewarded_at"] is None
+
+ rewarded = db.update_user_feedback_reward(
+ created["id"],
+ points=300,
+ reason="Valid data freshness report",
+ status="granted",
+ )
+
+ assert rewarded is not None
+ assert rewarded["reward_points"] == 300
+ assert rewarded["reward_reason"] == "Valid data freshness report"
+ assert rewarded["reward_status"] == "granted"
+ assert rewarded["rewarded_at"]
+
+ row = db.list_user_feedback(
+ limit=10,
+ user_id="user-reward",
+ user_email="reward@example.com",
+ )[0]
+ assert row["id"] == created["id"]
+ assert row["reward_points"] == 300
+ assert row["reward_reason"] == "Valid data freshness report"
+ assert row["reward_status"] == "granted"
+ assert row["rewarded_at"] == rewarded["rewarded_at"]
+
+
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
db = DBManager(str(tmp_path / "polyweather-feedback-identity.db"))
mine_by_user_id = db.append_user_feedback(