Skip to content

Commit 7030bea

Browse files
vayungodaraclaudeCopilotCodex
authored
fix: consolidated triage cleanup — all open findings from PRs #61/62/63/65 + LI-236/322 (#66)
* fix: consolidated triage cleanup — address all open findings from PRs #61/62/63/65 Addresses every actionable bug from 4 stale morning-triage PRs plus 2 critical visible regressions (LI-236, LI-322). 31 files changed, 0 lint errors, 104/104 tests pass, production build clean. ## Security & server (5) - next.config.mjs — add Content-Security-Policy header (9 directives: default/ script/style/img/font/connect/frame-ancestors/base-uri/form-action) - lib/partnerships.js — add UUID validator + guards on all 5 .or() call sites; batch notifyPartner N+1 inserts into single .insert([...]) call - lib/streaks-advanced.js — delete useStreakFreeze alias (ESLint hooks rule); add xp_events-based idempotency for streak-freeze milestone rewards - lib/email.js — add timeZone: 'UTC' + "(UTC)" suffix to deadline formatting - app/dashboard/pacts/PactsPageClient.js — defense-in-depth .eq('user_id') on pact delete ## Component logic (10) - components/Sidebar.js — SSR guard on localStorage access - components/FocusTimer.js — hoist defaultPalette to module scope, drop from useMemo deps - components/Toast.js — use crypto.randomUUID() for toast IDs - components/PactCard.js — remove `now` var shadow; normalize deadline day math to local midnight so "due today" is timezone-stable - components/TodayBar.js — resolve user IANA timezone and pass to checkStreakAtRisk + applyStreakFreeze - components/ActivityFeed.js — dedup loadMore append via Set of existing IDs - lib/FocusContext.js — remove redundant useEffect for handleTimerCompleteRef - app/dashboard/DashboardClient.js — reduce pacts limit from 200 to 50 (dashboard renders at most 3) - app/dashboard/DashboardLayout.js — log timezone-sync errors instead of swallowing them - components/DailySummaryCard.{js,module.css} — deleted (dead code replaced by TodayBar in dashboard redesign) ## Dark mode & visual regressions (5) - components/PactCard.module.css — add full dark coverage for base .card, :hover, .title/.description/.deadline text, all action buttons, badges (fixes LI-322: pact cards rendering as empty dark rectangles) - components/TaskCard.module.css — wrap bare [data-theme="dark"] selector in :global() (was broken in CSS Modules) + add .card/.actions dark overrides - components/CreatePactModal.module.css — full dark coverage (overlay, modal, inputs, buttons, template picker) with glass background - components/MonthlyCalendar.module.css — replace hardcoded #FFFFFF with var(--text-inverse) for dark-mode calendar cells - components/CompactActivityCard.js — replace inline Framer Motion with fadeInUp + cardHover presets from @/lib/animations ## Landing page LI-236 (1) - components/LandingPageClient.js — fix sections below hero rendering as blank dark void. Change initial state of 13 top-level whileInView configs from { opacity: 0 } → { opacity: 1 } so content paints on initial load even if IntersectionObserver fails to fire. Extract sectionViewport (amount: 0.05 + -10% margin) and sectionFadeInSafe constants. Verified with DOM inspection: hero/appPreview/featuresDark/howItWorks/ctaFooter all render at opacity 1. ## Micro-fixes & accessibility (6) - components/CreateGroupModal.js — maxLength={100} on group name; replace modular-bias `% 36` invite-code generation with rejection sampling - components/CreateTaskModal.js — aria-label="Task title" - components/NotificationBell.js — replace 25-frame rAF polling loop with single position calc + ResizeObserver; fix pre-existing react-hooks/set-state-in-effect on wiggle trigger via rAF defer - components/CreatePactModal.js — inline whileHover/Tap → buttonHover/ buttonTap presets - app/share/streak/page.js — sanitize searchParams (parseInt streak, strip <>"'& from name, cap 50 chars); remove unused prop - tests/landing.spec.js — scope Features/HowItWorks locators to nav:not([aria-label="Footer navigation"]) to resolve Playwright strict- mode collision with footer nav ## Test updates - tests/unit/lib/streaks-advanced.test.js — useStreakFreeze → applyStreakFreeze Closes PRs #61, #62, #63, #65 (all superseded by this consolidated fix set). Closes Notion LI-236, LI-322, and 18 other verified open findings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: XP system + Groups card sizing + streak RPCs ## XP / Streak fix (root cause: gamification trigger blocked all client writes) The protect_gamification_cols trigger on profiles was firing for any session role != service_role — silently overwriting NEW.total_xp := OLD.total_xp and related gamification columns. Client-side writes were no-op'd. Vayun's profile was stuck at 930 XP / streak 1 / last_activity_date 2026-04-11 despite logged xp_events totaling 735. ### Server (applied via migrations) - Fixed trigger to honor a session-var bypass (`app.skip_gamification_trigger`) - Updated `award_xp` RPC to set the bypass flag before UPDATE - Added `update_streak_activity(uuid, int, int, date)` RPC for streak writes - Added `consume_streak_freeze(uuid, date)` RPC for freeze use (updates last_activity_date + cooldown atomically) - Added `award_streak_freeze(uuid, int, int)` RPC for milestone rewards ### Client - `lib/FocusContext.js` — award 5 XP on focus session completion (was missing) - `components/TaskCard.js` — award 8 XP on task -> 'done' (was missing) - `lib/streaks-advanced.js` — use RPCs instead of direct profiles UPDATE: - `updateStreakOnCompletion` → `rpc('update_streak_activity')` - `applyStreakFreeze` → `rpc('consume_streak_freeze')` with local today date - `awardStreakFreeze` → `rpc('award_streak_freeze')` for milestone rewards - `lib/gamification.js` — log eventType/xpAmount/userId/pgCode on errors (prefix `[awardXP] Failed`) so future silent failures are visible - Removed unused imports (`formatUTCDate`) and dead code (`isSoundEnabled` in FocusContext, `members` prop in TaskCard) ## Groups card sizing fix The "hello" group with 0/20 tasks done was rendering at 2x width because: - `GroupsPage.module.css` had `.groupCardHero { grid-column: 1 / -1 }` - `GroupsPageClient.js` selected hero by `taskCount` (total), not completion ### Changes - Hero selection now requires >= 5 completed tasks (`tasksDone`), not total - Hero no longer spans full grid — consistent card sizes across rows - Hero styling now: accent border-left + layered glow shadow + gradient progress fill + "Most Active" lightning-bolt badge (inline SVG) - Dark-mode override updated to match Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: live XP refresh + confetti z-index ## XP live refresh `awardXP` now dispatches a `xp-updated` CustomEvent on success. `XPBar` listens for that event and re-fetches — so the dashboard XP bar updates instantly on pact/focus/task completion without a page reload. `Sidebar` and `MobileNav` already listened for this event; they just never got the signal because FocusContext/TaskCard XP paths had no broadcast. Centralizing the dispatch in `lib/gamification.js` means every awardXP call everywhere gets live UI. ## Confetti `useConfetti()` hook was missing `zIndex: 9999` — the other confetti helpers (fireSideConfetti, fireStars, fireMilestoneConfetti) set it but this one defaulted to 100, so any overlay/toast above it was hiding the bursts. Added zIndex: 9999 to `useConfetti`, `fireConfetti`, and `fireConfettiFromElement` for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(groups): cap sections to 3 with "Show all" toggle Matches the dashboard activity pattern — don't scroll forever. - Each section (Your Groups / Joined Groups) shows top 3 by default - Sort by tasksDone desc, then memberCount desc (real-activity signal) - "Show all N groups" / "Show less" toggle reveals the rest inline - Hero card selection unchanged (requires tasksDone >= 5) - New .showMoreBtn styled consistent with emptyActionSecondary + full dark-mode override Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(groups): cap activity feed on group detail — no endless scroll ActivityFeed now accepts `disableInfiniteScroll` prop. When true, the IntersectionObserver-based auto-load is skipped in favor of an explicit "Show more activity" button below the feed. Group detail page uses this with `pageSize={5}` so the section doesn't balloon to hundreds of items as the group gets busy. Dashboard activity behaviour unchanged (still pageSize=3 with external "View Older Activity" link to stats). Also reverted the previous Groups-page pagination — that was a misinterpretation of the request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * revert: undo Groups-page pagination (misread request) Revert the "Show all N groups" toggle on the Groups page — that was a misinterpretation. The real ask was about the activity feed inside a group detail page, already addressed in the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(groups): bounded activity panel with internal infinite scroll Instead of the manual "Show more" button, the group detail activity panel is now a fixed-height block (matches the left column) with infinite scroll inside. Matches the user's mock: one clean, organized block — scroll through all activities without pushing page content down. ### How it works - `.activityGrid` changed from `align-items: start` → `stretch` - New `.activityPanel` wrapper: height: 100%, min-height: 500, max-height: 720 - `.container` (ActivityFeed): `min-height: 0` so flex can shrink - `.feed`: `flex: 1; min-height: 0; overflow-y: auto;` + overscroll-contain - IntersectionObserver's scroll root is already the feed ref, so loadMore triggers as the user scrolls INSIDE the panel - Mobile/< 1024px: reverts to `align-items: start` with a smaller max-height ActivityFeed now uses pageSize={10} (default initial load). The `disableInfiniteScroll` prop + manual show-more button are left in for potential reuse (e.g. stats page) but no longer used here. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(e2e): align auth and landing assertions with current copy Agent-Logs-Url: https://github.com/vayungodara/lockin/sessions/39b6f264-427a-46ba-b139-a4ebacca14cf Co-authored-by: vayungodara <187050579+vayungodara@users.noreply.github.com> * fix: address all Claude + Codex review comments on PR #66 ## Critical fixes ### PactCard "Due tomorrow" regression (P0) `formatDeadline` reverted from local-midnight day math to ms-based hours-first math. Sub-24h deadlines crossing local midnight now correctly render "Due in 2 hours" instead of "Due tomorrow" while the card simultaneously pulses urgencyCritical red. ### Partnership notifications RLS (CRITICAL) `lib/partnerships.js#notifyPartner` was batch-inserting rows with `user_id: partnerId` — violated `WITH CHECK(auth.uid() = user_id)` on every row. Now routes through new `notify_partner` SECURITY DEFINER RPC that validates the caller is in an accepted partnership with each recipient before inserting. Preserves the batched write; bypasses RLS correctly. ### Streak idempotency marker RLS (Codex P1) `updateStreakOnCompletion` direct insert into `xp_events` was blocked by RLS (no client INSERT policy). Marker now routes through `awardXP` → `award_xp` RPC. The allowlist was extended with a prefix match for `streak_freeze_milestone_*` event types. `xp_amount=0` so no actual XP granted — pure idempotency marker. ### share/streak array-param TypeError (P2) `generateMetadata` crashed on `?name=a&name=b` — arrays don't have `.replace`. Coerce `searchParams.streak` and `searchParams.name` via `Array.isArray` check before string operations. ### TaskCard XP farming exploit (Codex P2) Toggling done → todo → done repeatedly awarded XP every cycle. Added idempotency guard: `newStatus === 'done' && task.status !== 'done'`. Also dropped `task.assignee_id` fallback (schema doesn't have that column — grep confirmed). ### MonthlyCalendar legibility regression (Claude P1) Reverted `#FFFFFF` → `var(--text-inverse)` on `.level3/.level4` heatmap cells. In dark mode `--text-inverse` is `#08090D` — black on medium-dark indigo is illegible. These cells have saturated accent bgs in BOTH themes, so pure white is correct. ### CSP hardening (Claude P2) Moved `'unsafe-eval'` behind `NODE_ENV === 'development'` gate. Added `vercel.live` to script-src/connect-src/frame-src for preview toolbar. Removed redundant `lh3.googleusercontent.com` (covered by wildcard). ### Dashboard pacts limit truncation (Codex P2) Bumped `.limit(50)` back to `.limit(200)` — active pacts could be pushed out of the window by users with >50 historical completed/missed pacts, breaking due-today/overdue counts. ### Sidebar localStorage SecurityError (Claude P3) `getSidebarCollapsed` runs via `useSyncExternalStore`'s getSnapshot — must be pure. Users with blocked third-party storage would throw SecurityError before `.getItem` ran, crashing the entire Sidebar subtree. Wrapped both getter and setter in try/catch. ### FocusContext ref mutation during render (Claude P3) Restored useEffect pattern for `handleTimerCompleteRef` sync. Inline render-phase ref writes are unsafe under React 18 concurrent mode — discarded renders can leave the ref pointing at uncommitted callbacks. ### NotificationBell dedup + sidebar-aware repositioning (P2 + Nit) Removed duplicated `reposition` function — reuse `updatePosition` directly. Added storage-event listener for `sidebar-collapsed` since the Sidebar is `position: fixed` and ResizeObserver on document.body never fires on sidebar toggle. Reposition happens 380ms after the animation completes. ## Cleanup ### LandingPageClient dead motion wrappers (Claude P3) After the LI-236 fix made 15+ sections start with opacity:1, the `whileInView` targets were identical no-ops. Converted static section containers to plain `<div>/<h2>/<p>` elements. Kept `motion.div` only where real motion (e.g. `whileHover={cardHover}`) remains. Removed `sectionFadeInSafe`, `sectionViewport`, `smoothTransition`. ### ActivityFeed dead disableInfiniteScroll prop (Claude P3) Removed the prop + manual "Show more" button ternary since we moved to "bounded container + internal infinite scroll". Dropped `.showMoreBtn` CSS block. ## Tests - Added RPC allowlist helper to supabase-mock (catches missing-RPC regressions going forward) - 3 new tests for streak-freeze milestone idempotency: 1. Awards on first milestone hit + inserts marker 2. Skips award on second call (marker exists) 3. Skips entirely on non-milestone day - Total: 107/107 passing (was 104) ## Repo sync Committed `supabase/gamification_hardening_2026_04_13.sql` — source of truth for all new RPCs (award_xp, update_streak_activity, consume_streak_freeze, award_streak_freeze, notify_partner) plus the protect_gamification_columns trigger bypass. Previously applied only to production via Supabase migrations; now tracked in the repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: align landing e2e with current copy Co-authored-by: vayungodara <187050579+vayungodara@users.noreply.github.com> * fix: address all 5 follow-up review comments on PR #66 Bot reviewers caught issues introduced in the previous fix push. ## CRITICAL — notify_partner status mismatch The new `notify_partner` RPC validated against `ap.status = 'accepted'` but `lib/partnerships.js:93` writes `'active'` for accepted requests. Every partner notification was silently a no-op. Fixed RPC to check `'active'`. SQL source of truth + production migration both updated. ## HIGH — TaskCard XP recipient broken for group owners `recipientId = task.owner_id || task.created_by || currentUser?.id` sent the award to the task owner first, but `award_xp` enforces `auth.uid() = p_user_id`. When a group owner closed someone else's task the award was rejected. Award now goes to `currentUser.id` (the actor who actually completed the work). Semantically more correct AND it actually succeeds. ## HIGH — Streak idempotency marker permanent `event_type = streak_freeze_milestone_7` was a global marker, so a user who broke their streak and rebuilt back to day 7 a month later would never earn another freeze. Marker now includes the streak's START date (`streak_freeze_milestone_7_2026-04-08`), so each unique streak run earns its own freeze. Test updated to match the regex pattern. ## MEDIUM — award_streak_freeze server-side milestone check Any authed user could call the RPC directly via the Supabase JS client to bump their freezes by up to MAX_FREEZES (5). RPC now reads `profiles.current_streak` server-side and rejects calls when the user is not on a known milestone (7/14/30/60/90/180/365). Also tightened `p_amount` to exactly 1. ## MEDIUM — consume_streak_freeze server-side cooldown The 3-day cooldown was only checked client-side in `lib/streaks-advanced.js:111-124`. RPC now re-checks `streak_freeze_last_used` server-side and returns a clear error if within the cooldown window. Direct API callers can no longer drain the entire pool in one session. ## Tests 107/107 still passing. Idempotency test updated to match new date-scoped marker format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vayungodara <187050579+vayungodara@users.noreply.github.com> Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
1 parent 04c0522 commit 7030bea

41 files changed

Lines changed: 1271 additions & 735 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/dashboard/DashboardClient.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export default function DashboardClient({ user }) {
8383
}
8484
}
8585

86+
// Dashboard only renders max 3 pacts, but the full set is used for
87+
// due-today/overdue counts in the header. limit=200 gives headroom for
88+
// active+historical so active pacts aren't pushed out of the window
89+
// when a user has a long tail of completed/missed pacts. Kept
90+
// select('*') to avoid drift with schema migrations that add new
91+
// columns (e.g. xp_reward, is_recurring).
8692
const { data, error } = await supabase
8793
.from('pacts')
8894
.select('*')

app/dashboard/DashboardLayout.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ export default function DashboardLayout({ user, children }) {
3434
.from('profiles')
3535
.update({ timezone: detected })
3636
.eq('id', user.id)
37-
.then(() => {}); // fire-and-forget
37+
.then(({ error }) => {
38+
if (error) console.warn('Timezone sync failed:', error.message);
39+
});
3840
}
3941
} catch {
4042
// Intl API unavailable — timezone stays as DB default ('UTC')

app/dashboard/groups/GroupsPage.module.css

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,61 @@
8787
gap: var(--space-4);
8888
}
8989

90-
/* Hero card — most active group gets visual emphasis */
90+
/* Hero card — most active group gets visual emphasis without breaking the grid */
9191
.groupCardHero {
92-
grid-column: 1 / -1;
9392
border-left: 3px solid var(--accent-primary);
93+
box-shadow:
94+
0 0 0 1px rgba(var(--accent-primary-rgb), 0.2),
95+
0 4px 16px rgba(var(--accent-primary-rgb), 0.08);
96+
}
97+
98+
.groupCardHero:hover {
99+
box-shadow:
100+
var(--shadow-md),
101+
0 0 0 1px var(--accent-primary),
102+
0 6px 20px rgba(var(--accent-primary-rgb), 0.14);
103+
}
104+
105+
.groupCardHero .progressFill {
106+
height: 6px;
107+
background: linear-gradient(
108+
90deg,
109+
var(--accent-primary),
110+
var(--accent-secondary, var(--accent-primary))
111+
);
112+
}
113+
114+
.groupCardHero .progressBar {
115+
height: 6px;
116+
}
117+
118+
/* "Most Active" celebratory badge — anchors top-right of hero card */
119+
.heroBadge {
120+
position: absolute;
121+
top: var(--space-3);
122+
right: var(--space-3);
123+
display: inline-flex;
124+
align-items: center;
125+
gap: 4px;
126+
padding: 3px var(--space-2);
127+
background: var(--accent-glow);
128+
color: var(--accent-primary);
129+
font-size: 10px;
130+
font-weight: 700;
131+
letter-spacing: 0.04em;
132+
text-transform: uppercase;
133+
border-radius: var(--radius-sm);
134+
line-height: 1;
135+
white-space: nowrap;
136+
pointer-events: none;
137+
}
138+
139+
.heroBadge svg {
140+
flex-shrink: 0;
94141
}
95142

96143
.groupCard {
144+
position: relative;
97145
display: flex;
98146
flex-direction: column;
99147
gap: var(--space-3);
@@ -435,7 +483,20 @@
435483

436484
:global([data-theme="dark"]) .groupCardHero {
437485
border-left-color: var(--accent-primary);
438-
box-shadow: var(--shadow-sm), inset 3px 0 12px -4px rgba(var(--accent-primary-rgb), 0.15);
486+
box-shadow:
487+
0 0 0 1px rgba(var(--accent-primary-rgb), 0.3),
488+
0 4px 20px rgba(var(--accent-primary-rgb), 0.18);
489+
}
490+
491+
:global([data-theme="dark"]) .groupCardHero:hover {
492+
box-shadow:
493+
var(--shadow-lg),
494+
0 0 0 1px var(--accent-primary),
495+
0 0 28px rgba(var(--accent-primary-rgb), 0.28);
496+
}
497+
498+
:global([data-theme="dark"]) .heroBadge {
499+
background: rgba(var(--accent-primary-rgb), 0.18);
439500
}
440501

441502
:global([data-theme="dark"]) .metaDot {

app/dashboard/groups/GroupsPageClient.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ function GroupCard({ group, isHero = false }) {
3535

3636
return (
3737
<Link href={`/dashboard/groups/${group.id}`} className={`${styles.groupCard} ${isHero ? styles.groupCardHero : ''}`}>
38+
{isHero && (
39+
<span className={styles.heroBadge} aria-label="Most active group">
40+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
41+
<path d="M13 2L4.5 13H11L10 22L18.5 11H12L13 2Z" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
42+
</svg>
43+
Most Active
44+
</span>
45+
)}
3846
<div className={styles.groupHeader}>
3947
<div
4048
className={styles.groupIcon}
@@ -115,10 +123,14 @@ function GroupSections({ groups }) {
115123
const ownedGroups = groups.filter(g => g.role === 'owner');
116124
const joinedGroups = groups.filter(g => g.role !== 'owner');
117125

118-
// Identify the most active group (highest task count) for hero treatment.
119-
// Only apply hero when there are 3+ groups to avoid odd layout with 1-2 cards.
126+
// Hero: the group with the most COMPLETED tasks (real progress signal).
127+
// Require >= 5 completed tasks to avoid celebrating empty groups.
128+
// Only apply with 3+ groups to avoid odd layout with 1-2 cards.
120129
const mostActiveId = groups.length >= 3
121-
? groups.reduce((best, g) => (g.taskCount > (best?.taskCount || 0) ? g : best), null)?.id
130+
? groups.reduce((best, g) => {
131+
if ((g.tasksDone || 0) < 5) return best;
132+
return (g.tasksDone || 0) > (best?.tasksDone || 0) ? g : best;
133+
}, null)?.id
122134
: null;
123135

124136
return (

app/dashboard/groups/[id]/GroupDetail.module.css

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,12 +456,35 @@
456456
display: grid;
457457
grid-template-columns: 1fr 1.5fr;
458458
gap: var(--space-4);
459-
align-items: start;
459+
align-items: stretch;
460+
}
461+
462+
/* Activity panel matches the left column's height so the whole activity
463+
feed lives inside one scrollable block instead of endlessly expanding
464+
the page. The nested ActivityFeed's IntersectionObserver already roots
465+
on its own feed ref, so infinite scroll happens INSIDE this box. */
466+
.activityPanel {
467+
height: 100%;
468+
min-height: 500px;
469+
max-height: 720px;
470+
display: flex;
471+
flex-direction: column;
472+
}
473+
474+
.activityPanel > * {
475+
flex: 1;
476+
min-height: 0;
460477
}
461478

462479
@media (max-width: 1024px) {
463480
.activityGrid {
464481
grid-template-columns: 1fr;
482+
align-items: start;
483+
}
484+
485+
.activityPanel {
486+
height: auto;
487+
max-height: 600px;
465488
}
466489
}
467490

app/dashboard/groups/[id]/GroupDetailClient.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,9 @@ export default function GroupDetailClient({ user, group, userRole }) {
434434
)}
435435
<GroupStats groupId={group.id} />
436436
</div>
437-
<ActivityFeed groupId={group.id} />
437+
<div className={styles.activityPanel}>
438+
<ActivityFeed groupId={group.id} pageSize={10} />
439+
</div>
438440
</div>
439441
</section>
440442
)}

app/dashboard/pacts/PactsPageClient.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,14 @@ export default function PactsPageClient({ user }) {
6666

6767
const handleDeletePact = async (pactId) => {
6868
try {
69+
// Defense-in-depth: RLS already scopes this to the caller, but adding
70+
// the explicit user_id filter ensures a hostile/misconfigured policy
71+
// can't expand the delete's blast radius beyond the caller's pacts.
6972
const { error } = await supabase
7073
.from('pacts')
7174
.delete()
72-
.eq('id', pactId);
75+
.eq('id', pactId)
76+
.eq('user_id', user.id);
7377

7478
if (error) throw error;
7579
setPacts(prev => prev.filter(p => p.id !== pactId));

app/share/streak/page.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ import ShareStreakClient from './ShareStreakClient';
55

66
export async function generateMetadata({ searchParams }) {
77
const params = await searchParams;
8-
const streak = params?.streak || '0';
9-
const name = params?.name || 'Someone';
10-
8+
// Sanitize untrusted query params to prevent XSS / metadata injection.
9+
// searchParams values can be `string | string[] | undefined` when the same
10+
// key appears multiple times (e.g. `?name=a&name=b`). Coerce to a single
11+
// string before calling `.replace` to avoid a TypeError on arrays.
12+
const rawStreak = Array.isArray(params?.streak) ? params.streak[0] : params?.streak;
13+
const streak = String(parseInt(rawStreak, 10) || 0);
14+
const rawName = Array.isArray(params?.name) ? params.name[0] : params?.name;
15+
const name = (rawName || 'Someone').replace(/[<>"'&]/g, '').slice(0, 50);
16+
1117
return {
1218
title: `${name} is on a ${streak}-day streak! | LockIn`,
1319
description: `${name} has been crushing their goals with LockIn. Join them!`,
@@ -18,7 +24,7 @@ export async function generateMetadata({ searchParams }) {
1824
};
1925
}
2026

21-
export default async function ShareStreakPage({ searchParams }) {
27+
export default async function ShareStreakPage() {
2228
const supabase = await createClient();
2329
const { data: { user } } = await supabase.auth.getUser();
2430

components/ActivityFeed.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,10 @@ export default function ActivityFeed({ groupId = null, pageSize = DEFAULT_PAGE_S
135135
const newActivities = result.data || [];
136136

137137
offsetRef.current = currentOffset + newActivities.length;
138-
setActivities(prev => [...prev, ...newActivities]);
138+
setActivities(prev => {
139+
const existingIds = new Set(prev.map(a => a.id));
140+
return [...prev, ...newActivities.filter(a => !existingIds.has(a.id))];
141+
});
139142
setHasMore(newActivities.length === pageSize);
140143
} catch (err) {
141144
console.error('Error loading more:', err);
@@ -237,7 +240,7 @@ export default function ActivityFeed({ groupId = null, pageSize = DEFAULT_PAGE_S
237240
<ActivityItem key={activity.id} activity={activity} />
238241
))}
239242

240-
{/* Sentinel element for infinite scroll */}
243+
{/* Sentinel element — IntersectionObserver auto-loads more as user scrolls */}
241244
<div ref={sentinelCallbackRef} className={styles.sentinel}>
242245
{isLoadingMore && (
243246
<div className={styles.loadingMore}>

components/ActivityFeed.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
border-radius: var(--radius-lg);
55
display: flex;
66
flex-direction: column;
7+
min-height: 0;
78
}
89

910
.header {
@@ -31,6 +32,13 @@
3132
padding: var(--space-3) var(--space-4);
3233
display: flex;
3334
flex-direction: column;
35+
/* When the parent constrains height, scroll happens INSIDE the feed
36+
— the IntersectionObserver's scroll root is this element, so
37+
infinite scroll auto-loads more as the user scrolls here. */
38+
flex: 1;
39+
min-height: 0;
40+
overflow-y: auto;
41+
overscroll-behavior: contain;
3442
}
3543

3644
.loading {

0 commit comments

Comments
 (0)