✨ Add subtle engagement psychology: visit streak + rotating tips (#3791)#4067
✨ Add subtle engagement psychology: visit streak + rotating tips (#3791)#4067clubanderson merged 1 commit intomainfrom
Conversation
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>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
✅ Deploy Preview for kubestellarconsole ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
👋 Hey @clubanderson — thanks for opening this PR!
This is an automated message. |
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
There was a problem hiding this comment.
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
useVisitStreakhook andStreakBadgenavbar UI to track/display consecutive daily visits. - Added a rotating tip selection module and integrated it into
GettingStartedBanner, emittingksc_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 viauseState(() => getRandomTip())even when the banner returnsnull(hintsSuppressedordismissed). As a result,emitTipShown(andemitGettingStartedShown) can fire even when the banner is suppressed, and the “seen tips” state advances even though no tip was rendered. Consider computinghintsSuppressedbefore the analytics effect, gating the effect on!hintsSuppressed && !dismissed, and only selecting/persisting a tip when the banner is actually shown (e.g.,randomTipstate startsnulland 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
| // 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 |
There was a problem hiding this comment.
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.
| /** 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 |
There was a problem hiding this comment.
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.
| /** 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 | ||
| } |
There was a problem hiding this comment.
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).
| export const STORAGE_KEY_VISIT_STREAK = 'ksc-visit-streak' | ||
| export const STORAGE_KEY_SEEN_TIPS = 'ksc-seen-tips' |
There was a problem hiding this comment.
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-).
| 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' |
🔄 Auto-Applying Copilot Code ReviewCopilot code review found 1 code suggestion(s) and 3 general comment(s). @copilot Please apply all of the following code review suggestions:
Also address these general comments:
Push all fixes in a single commit. Run Auto-generated by copilot-review-apply workflow. |
Summary
ksc_streak_dayGA4 event on increment.ksc_tip_shownGA4 event.text-xs text-muted-foregroundstyling — minimal, non-intrusive, blends in.Fixes #3791
Files changed
web/src/hooks/useVisitStreak.tsweb/src/components/layout/navbar/StreakBadge.tsxweb/src/lib/tips.tsweb/src/lib/constants/storage.tsSTORAGE_KEY_VISIT_STREAKandSTORAGE_KEY_SEEN_TIPSweb/src/lib/analytics.tsemitStreakDay()GA4 eventweb/src/components/layout/navbar/Navbar.tsx<StreakBadge />in extended desktop itemsweb/src/components/dashboard/GettingStartedBanner.tsxTest plan
ksc-visit-streakto{"lastVisitDate":"YYYY-MM-DD","currentStreak":3}with yesterday's date, reload — badge should show fire emoji + 4ksc_streak_day,ksc_tip_shownnpm run build)