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(