use existing config system instead of local storage for showcase tracking (vibe-kanban) (#1049)

* Perfect! All tasks completed successfully. Here's what was implemented:

## Summary

 **Migrated showcase persistence from localStorage to v8 config system**

**Backend (Rust):**
- Created [v8.rs](file:///private/var/folders/5q/5vgq75y92dz0k7n62z93299r0000gn/T/vibe-kanban/worktrees/0960-use-existing-con/crates/services/src/services/config/versions/v8.rs) with `ShowcaseState` struct tracking `seen_versions: HashMap<String, u32>`
- Implemented migration from v7→v8 with default empty showcases
- Wired up v8 as active config version

**Frontend (TypeScript):**
- Created [useShowcasePersistence.ts](file:///private/var/folders/5q/5vgq75y92dz0k7n62z93299r0000gn/T/vibe-kanban/worktrees/0960-use-existing-con/frontend/src/hooks/useShowcasePersistence.ts) hook using config API
- Refactored [useShowcaseTrigger.ts](file:///private/var/folders/5q/5vgq75y92dz0k7n62z93299r0000gn/T/vibe-kanban/worktrees/0960-use-existing-con/frontend/src/hooks/useShowcaseTrigger.ts) to use new persistence (kept API unchanged)
- Deleted old localStorage utility
- Generated TypeScript types including `ShowcaseState`

**Key Features:**
- Showcase state persists to config file (survives random port changes)
- Tracks highest version seen per showcase ID
- Optimistic updates for responsive UX
- Automatic v7→v8 migration on startup

* Cleanup script changes for task attempt 096017a4-28dd-4092-a3ef-7180232ee7d2

* Done! Now `default_git_branch_prefix` is imported from v7 instead of being duplicated.

* Cleanup script changes for task attempt 096017a4-28dd-4092-a3ef-7180232ee7d2

* fix FeatureShowcaseModal race condition (vibe-kanban) (#1050)

* Done! Collapsed v8 back into v7. The `showcases` field is now part of v7 with `#[serde(default)]`, making it backward compatible without needing a version bump.

* Cleanup script changes for task attempt 096017a4-28dd-4092-a3ef-7180232ee7d2

* Fixed! 

* showcase simplification (vibe-kanban 94972ea4)

frontend/src/config/showcases.ts
I want to make it harder to make mistakes here, lets use the type system to our advantage.

1. We could forget to update the `export const showcases` variable, so instead of expecting `taskPanelShowcase`, we should move showcases to all be under one object and only access them through that object.

2. The versioning system is overkill, instead of a `showcases` map with `seen_versions` as another map, we can have a `seen_features` set (realistically an array in JSON) that we insert keys into.
This commit is contained in:
Britannio Jarrett
2025-10-17 18:43:40 +01:00
committed by GitHub
parent e4a4c004da
commit 50d285cfe5
11 changed files with 121 additions and 93 deletions

View File

@@ -78,17 +78,17 @@ export function FeatureShowcaseModal({
);
const handleNext = () => {
if (currentStage < totalStages - 1) {
setCurrentStage((prev) => prev + 1);
} else {
onClose();
}
setCurrentStage((prev) => {
if (prev >= totalStages - 1) {
onClose();
return prev;
}
return prev + 1;
});
};
const handlePrevious = () => {
if (currentStage > 0) {
setCurrentStage((prev) => prev - 1);
}
setCurrentStage((prev) => Math.max(prev - 1, 0));
};
return (

View File

@@ -1,44 +1,41 @@
import { ShowcaseConfig } from '@/types/showcase';
export const taskPanelShowcase: ShowcaseConfig = {
id: 'task-panel-onboarding',
version: 1,
stages: [
{
titleKey: 'showcases.taskPanel.companion.title',
descriptionKey: 'showcases.taskPanel.companion.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-companion-demo-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.installation.title',
descriptionKey: 'showcases.taskPanel.installation.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-install-companion-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.codeReview.title',
descriptionKey: 'showcases.taskPanel.codeReview.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-code-review-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.pullRequest.title',
descriptionKey: 'showcases.taskPanel.pullRequest.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-git-pr-3.mp4',
},
},
],
};
export const showcases = {
taskPanel: taskPanelShowcase,
};
taskPanel: {
id: 'task-panel-onboarding',
stages: [
{
titleKey: 'showcases.taskPanel.companion.title',
descriptionKey: 'showcases.taskPanel.companion.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-companion-demo-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.installation.title',
descriptionKey: 'showcases.taskPanel.installation.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-install-companion-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.codeReview.title',
descriptionKey: 'showcases.taskPanel.codeReview.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-code-review-3.mp4',
},
},
{
titleKey: 'showcases.taskPanel.pullRequest.title',
descriptionKey: 'showcases.taskPanel.pullRequest.description',
media: {
type: 'video',
src: 'https://vkcdn.britannio.dev/showcase/flat-task-panel/vk-onb-git-pr-3.mp4',
},
},
],
} satisfies ShowcaseConfig,
} as const;

View File

@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { useUserSystem } from '@/components/config-provider';
export interface ShowcasePersistence {
hasSeen: (id: string) => boolean;
markSeen: (id: string) => Promise<void>;
isLoaded: boolean;
}
export function useShowcasePersistence(): ShowcasePersistence {
const { config, updateAndSaveConfig, loading } = useUserSystem();
const seenFeatures = config?.showcases?.seen_features ?? [];
const hasSeen = useCallback(
(id: string): boolean => {
return seenFeatures.includes(id);
},
[seenFeatures]
);
const markSeen = useCallback(
async (id: string): Promise<void> => {
if (seenFeatures.includes(id)) {
return;
}
await updateAndSaveConfig({
showcases: {
seen_features: [...seenFeatures, id],
},
});
},
[seenFeatures, updateAndSaveConfig]
);
return {
hasSeen,
markSeen,
isLoaded: !loading,
};
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ShowcaseConfig } from '@/types/showcase';
import { hasSeen as hasSeenUtil, markSeen } from '@/utils/showcasePersistence';
import { useShowcasePersistence } from './useShowcasePersistence';
export interface ShowcaseTriggerOptions {
enabled: boolean;
@@ -27,17 +27,17 @@ export function useShowcaseTrigger(
markSeenOnClose = true,
} = options;
const persistence = useShowcasePersistence();
const [isOpen, setIsOpen] = useState(false);
const [hasSeen, setHasSeen] = useState(() =>
hasSeenUtil(config.id, config.version)
);
const [hasSeenState, setHasSeenState] = useState(false);
const timerRef = useRef<number | null>(null);
const mountedRef = useRef(true);
// Keep 'hasSeen' in sync if id/version change
// Keep 'hasSeenState' in sync if id change or config loads
useEffect(() => {
setHasSeen(hasSeenUtil(config.id, config.version));
}, [config.id, config.version]);
if (!persistence.isLoaded) return;
setHasSeenState(persistence.hasSeen(config.id));
}, [persistence.isLoaded, config.id, persistence]);
// Cleanup timers
useEffect(() => {
@@ -53,9 +53,11 @@ export function useShowcaseTrigger(
// Handle enabled state changes
useEffect(() => {
if (!persistence.isLoaded) return;
if (enabled) {
// Only show if not seen
if (!hasSeen) {
if (!hasSeenState) {
// Clear any existing timer
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
@@ -87,7 +89,7 @@ export function useShowcaseTrigger(
timerRef.current = null;
}
};
}, [enabled, hasSeen, openDelay, resetOnDisable]);
}, [persistence.isLoaded, enabled, hasSeenState, openDelay, resetOnDisable]);
const open = useCallback(() => {
setIsOpen(true);
@@ -95,15 +97,15 @@ export function useShowcaseTrigger(
const close = useCallback(() => {
if (markSeenOnClose) {
markSeen(config.id, config.version);
setHasSeen(true);
persistence.markSeen(config.id);
setHasSeenState(true);
}
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setIsOpen(false);
}, [config.id, config.version, markSeenOnClose]);
}, [config.id, markSeenOnClose, persistence]);
return { isOpen, open, close, hasSeen };
return { isOpen, open, close, hasSeen: hasSeenState };
}

View File

@@ -9,7 +9,7 @@ import { tasksApi } from '@/lib/api';
import type { GitBranch } from 'shared/types';
import { openTaskForm } from '@/lib/openTaskForm';
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
import { taskPanelShowcase } from '@/config/showcases';
import { showcases } from '@/config/showcases';
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
import { useSearch } from '@/contexts/search-context';
@@ -157,7 +157,7 @@ export function ProjectTasks() {
const isPanelOpen = Boolean(taskId && selectedTask);
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
useShowcaseTrigger(taskPanelShowcase, {
useShowcaseTrigger(showcases.taskPanel, {
enabled: isPanelOpen,
});
@@ -766,7 +766,7 @@ export function ProjectTasks() {
<FeatureShowcaseModal
isOpen={showTaskPanelShowcase}
onClose={closeTaskPanelShowcase}
config={taskPanelShowcase}
config={showcases.taskPanel}
/>
</div>
);

View File

@@ -13,6 +13,5 @@ export interface ShowcaseStage {
export interface ShowcaseConfig {
id: string;
version: number;
stages: ShowcaseStage[];
}

View File

@@ -1,26 +0,0 @@
/**
* Check if a user has seen a specific showcase version
*
* @param id - Unique identifier for the showcase
* @param version - Version number for the showcase
* @returns true if the user has seen this showcase version
*
* Storage key format: `showcase:{id}:v{version}:seen`
*/
export function hasSeen(id: string, version: number): boolean {
const key = `showcase:${id}:v${version}:seen`;
return localStorage.getItem(key) === 'true';
}
/**
* Mark a showcase as seen
*
* @param id - Unique identifier for the showcase
* @param version - Version number for the showcase
*
* Storage key format: `showcase:{id}:v{version}:seen`
*/
export function markSeen(id: string, version: number): void {
const key = `showcase:${id}:v${version}:seen`;
localStorage.setItem(key, 'true');
}