Skip to content

✨ Add subtle engagement psychology: visit streak + rotating tips (#3791)#4067

Merged
clubanderson merged 1 commit intomainfrom
feat/engagement-psychology
Apr 1, 2026
Merged

✨ Add subtle engagement psychology: visit streak + rotating tips (#3791)#4067
clubanderson merged 1 commit intomainfrom
feat/engagement-psychology

Conversation

@clubanderson
Copy link
Copy Markdown
Collaborator

Summary

  • Visit streak badge (navbar): Small flame emoji + day count next to TokenUsageWidget, showing consecutive daily visits. Only appears when streak >= 2. Tracks in localStorage, fires ksc_streak_day GA4 event on increment.
  • Rotating tips (Getting Started banner): A single "Did you know?" line at the bottom of the existing Welcome banner. Cycles through 18 tips without repeating until all are shown. Fires ksc_tip_shown GA4 event.
  • Both features use text-xs text-muted-foreground styling — minimal, non-intrusive, blends in.

Fixes #3791

Files changed

File Change
web/src/hooks/useVisitStreak.ts New hook — tracks consecutive daily visits in localStorage
web/src/components/layout/navbar/StreakBadge.tsx New component — renders streak badge (hidden if < 2 days)
web/src/lib/tips.ts New module — 18 tips with seen-tip cycling via localStorage
web/src/lib/constants/storage.ts Added STORAGE_KEY_VISIT_STREAK and STORAGE_KEY_SEEN_TIPS
web/src/lib/analytics.ts Added emitStreakDay() GA4 event
web/src/components/layout/navbar/Navbar.tsx Added <StreakBadge /> in extended desktop items
web/src/components/dashboard/GettingStartedBanner.tsx Added rotating tip line at bottom of banner

Test plan

  • Visit the console — streak badge should NOT appear (streak = 1)
  • Set localStorage ksc-visit-streak to {"lastVisitDate":"YYYY-MM-DD","currentStreak":3} with yesterday's date, reload — badge should show fire emoji + 4
  • Verify streak resets to 1 after missing a day
  • Verify Getting Started banner shows a tip line at the bottom
  • Reload multiple times — tips should rotate (no immediate repeats)
  • Verify GA4 events fire: ksc_streak_day, ksc_tip_shown
  • Build passes (npm run build)
  • No new lint errors in changed files

Visit streak badge (navbar): Shows consecutive daily visits as a small
flame emoji + number next to TokenUsageWidget. Only displays when
streak >= 2. Tracks visits in localStorage and fires ksc_streak_day
GA4 event when streak increments.

Rotating tips (Getting Started banner): Displays a random "Did you
know?" tip at the bottom of the existing Welcome banner. Cycles through
18 tips without repeating until all have been shown. Fires ksc_tip_shown
GA4 event for each tip displayed.

Both features are intentionally minimal — text-xs text-muted-foreground
styling, no animations, no modals, no overlays.

Fixes #3791

Signed-off-by: Andrew Anderson <andy@clubanderson.com>
Copilot AI review requested due to automatic review settings April 1, 2026 02:44
@kubestellar-prow kubestellar-prow bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Apr 1, 2026
@kubestellar-prow
Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign clubanderson for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 1, 2026

Deploy Preview for kubestellarconsole ready!

Name Link
🔨 Latest commit 743d29d
🔍 Latest deploy log https://app.netlify.com/projects/kubestellarconsole/deploys/69cc8694ce53a000080b81f1
😎 Deploy Preview https://deploy-preview-4067.console-deploy-preview.kubestellar.io
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

👋 Hey @clubanderson — thanks for opening this PR!

🤖 This project is developed exclusively using AI coding assistants.

Please do not attempt to code anything for this project manually.
All contributions should be authored using an AI coding tool such as:

This ensures consistency in code style, architecture patterns, test coverage,
and commit quality across the entire codebase.


This is an automated message.

@kubestellar-prow kubestellar-prow bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Apr 1, 2026
@clubanderson clubanderson merged commit d4239db into main Apr 1, 2026
21 of 23 checks passed
@kubestellar-prow kubestellar-prow bot deleted the feat/engagement-psychology branch April 1, 2026 02:46
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

Thank you for your contribution! Your PR has been merged.

Check out what's new:

Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds lightweight engagement features to the web console UI: a visit streak indicator in the navbar and a rotating “Did you know?” tip in the Getting Started banner, with GA4 events to measure exposure/increment behavior.

Changes:

  • Added a useVisitStreak hook and StreakBadge navbar UI to track/display consecutive daily visits.
  • Added a rotating tip selection module and integrated it into GettingStartedBanner, emitting ksc_tip_shown.
  • Added storage key constants and a new analytics event ksc_streak_day.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web/src/lib/tips.ts Adds tip list + non-repeating selection persisted in localStorage
web/src/lib/constants/storage.ts Adds new localStorage key constants for streak + seen tips
web/src/lib/analytics.ts Adds GA4 emitter for streak increment event
web/src/hooks/useVisitStreak.ts New hook to compute/persist streak and emit analytics
web/src/components/layout/navbar/StreakBadge.tsx New small navbar badge component
web/src/components/layout/navbar/Navbar.tsx Renders the new StreakBadge in the navbar
web/src/components/dashboard/GettingStartedBanner.tsx Displays rotating tip line and emits tip analytics
Comments suppressed due to low confidence (1)

web/src/components/dashboard/GettingStartedBanner.tsx:58

  • getRandomTip() mutates localStorage and is called unconditionally via useState(() => getRandomTip()) even when the banner returns null (hintsSuppressed or dismissed). As a result, emitTipShown (and emitGettingStartedShown) can fire even when the banner is suppressed, and the “seen tips” state advances even though no tip was rendered. Consider computing hintsSuppressed before the analytics effect, gating the effect on !hintsSuppressed && !dismissed, and only selecting/persisting a tip when the banner is actually shown (e.g., randomTip state starts null and is set inside an effect when shown).
  // Pick a random tip once on mount (stable across re-renders)
  const [randomTip] = useState(() => getRandomTip())

  // Emit analytics once on first render
  useEffect(() => {
    if (!dismissed && !emittedRef.current) {
      emittedRef.current = true
      emitGettingStartedShown()
      emitTipShown('dashboard', randomTip.tip)
    }
  }, [dismissed, randomTip.tip])

  const [hintsSuppressed] = useState(
    () => safeGetItem(STORAGE_KEY_HINTS_SUPPRESSED) === 'true'
  )

  if (hintsSuppressed || dismissed) return null

Comment on lines +54 to +65
// Check if lastVisitDate was yesterday
const lastDate = new Date(stored.lastVisitDate)
const now = new Date()
// Normalize both to midnight to compare calendar days
const lastMidnight = new Date(lastDate.getFullYear(), lastDate.getMonth(), lastDate.getDate())
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = todayMidnight.getTime() - lastMidnight.getTime()

/** Exactly one calendar day difference */
const isYesterday = diffMs === MS_PER_DAY

const newStreak = isYesterday ? stored.currentStreak + 1 : 1
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stored.lastVisitDate is stored as a YYYY-MM-DD string, but new Date(stored.lastVisitDate) is parsed as UTC by JS. That can shift the calendar date in many timezones, making the streak calculation incorrect. Also, diffMs === MS_PER_DAY will break across DST transitions (a “day” is not always exactly 86,400,000ms). Prefer comparing date strings in local time (e.g., compute yesterday = toDateString(new Date(Date.now() - 86400000)) using local-midnight math) or store a numeric local-midnight timestamp instead of parsing YYYY-MM-DD with Date.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +77
/** Calculate the streak on mount (runs once via useState initializer) */
function calculateStreak(): number {
const today = toDateString(new Date())
const stored = safeGetJSON<StreakData>(STORAGE_KEY_VISIT_STREAK)

if (!stored || !stored.lastVisitDate) {
// First visit ever — start at 1
safeSetJSON<StreakData>(STORAGE_KEY_VISIT_STREAK, {
lastVisitDate: today,
currentStreak: 1,
})
return 1
}

if (stored.lastVisitDate === today) {
// Already visited today — no change
return stored.currentStreak
}

// Check if lastVisitDate was yesterday
const lastDate = new Date(stored.lastVisitDate)
const now = new Date()
// Normalize both to midnight to compare calendar days
const lastMidnight = new Date(lastDate.getFullYear(), lastDate.getMonth(), lastDate.getDate())
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = todayMidnight.getTime() - lastMidnight.getTime()

/** Exactly one calendar day difference */
const isYesterday = diffMs === MS_PER_DAY

const newStreak = isYesterday ? stored.currentStreak + 1 : 1

safeSetJSON<StreakData>(STORAGE_KEY_VISIT_STREAK, {
lastVisitDate: today,
currentStreak: newStreak,
})

// Fire GA4 event when streak actually increments (not resets)
if (isYesterday) {
emitStreakDay(newStreak)
}

return newStreak
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculateStreak() performs side effects (localStorage writes + analytics emit) inside a useState initializer, which runs during render and is intentionally double-invoked under React.StrictMode in development. Consider moving the persistence + emitStreakDay into a useEffect (guarded so it runs once) and keeping the state initializer pure to avoid surprising dev-only behavior and to align with React’s render purity expectations.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +78
/** Calculate the streak on mount (runs once via useState initializer) */
function calculateStreak(): number {
const today = toDateString(new Date())
const stored = safeGetJSON<StreakData>(STORAGE_KEY_VISIT_STREAK)

if (!stored || !stored.lastVisitDate) {
// First visit ever — start at 1
safeSetJSON<StreakData>(STORAGE_KEY_VISIT_STREAK, {
lastVisitDate: today,
currentStreak: 1,
})
return 1
}

if (stored.lastVisitDate === today) {
// Already visited today — no change
return stored.currentStreak
}

// Check if lastVisitDate was yesterday
const lastDate = new Date(stored.lastVisitDate)
const now = new Date()
// Normalize both to midnight to compare calendar days
const lastMidnight = new Date(lastDate.getFullYear(), lastDate.getMonth(), lastDate.getDate())
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = todayMidnight.getTime() - lastMidnight.getTime()

/** Exactly one calendar day difference */
const isYesterday = diffMs === MS_PER_DAY

const newStreak = isYesterday ? stored.currentStreak + 1 : 1

safeSetJSON<StreakData>(STORAGE_KEY_VISIT_STREAK, {
lastVisitDate: today,
currentStreak: newStreak,
})

// Fire GA4 event when streak actually increments (not resets)
if (isYesterday) {
emitStreakDay(newStreak)
}

return newStreak
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no unit tests for this new date/streak logic. The repo has extensive hook tests under web/src/hooks/__tests__, so please add coverage for cases like: first visit, same-day revisit, yesterday increment, missed-day reset, invalid/corrupted storage data, and DST boundary behavior (if applicable to the chosen implementation).

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +65
export const STORAGE_KEY_VISIT_STREAK = 'ksc-visit-streak'
export const STORAGE_KEY_SEEN_TIPS = 'ksc-seen-tips'
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new keys use the ksc- prefix, but migrateFromLocalStorage() migrates/removes any ksc- keys to kc- on startup in some code paths (e.g., when the cache worker init falls back). That can silently rename ksc-visit-streak/ksc-seen-tips and break these features’ persistence because the code only reads the ksc- keys. Use the canonical kc- prefix for new keys (or read from both and write to kc-).

Suggested change
export const STORAGE_KEY_VISIT_STREAK = 'ksc-visit-streak'
export const STORAGE_KEY_SEEN_TIPS = 'ksc-seen-tips'
export const STORAGE_KEY_VISIT_STREAK = 'kc-visit-streak'
export const STORAGE_KEY_SEEN_TIPS = 'kc-seen-tips'

Copilot uses AI. Check for mistakes.
@clubanderson
Copy link
Copy Markdown
Collaborator Author

🔄 Auto-Applying Copilot Code Review

Copilot code review found 1 code suggestion(s) and 3 general comment(s).

@copilot Please apply all of the following code review suggestions:

  • web/src/lib/constants/storage.ts (line 65): export const STORAGE_KEY_VISIT_STREAK = 'kc-visit-streak' export const STORAGE_K...

Also address these general comments:

  • web/src/hooks/useVisitStreak.ts (line 65): stored.lastVisitDate is stored as a YYYY-MM-DD string, but new Date(stored.lastVisitDate) is parsed as UTC by JS.
  • web/src/hooks/useVisitStreak.ts (line 77): calculateStreak() performs side effects (localStorage writes + analytics emit) inside a useState initializer, which
  • web/src/hooks/useVisitStreak.ts (line 78): There are no unit tests for this new date/streak logic. The repo has extensive hook tests under `web/src/hooks/tests

Push all fixes in a single commit. Run cd web && npm run build && npm run lint before committing.


Auto-generated by copilot-review-apply workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Auto-QA] 1 adoption & engagement psychology opportunities found

3 participants