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

@@ -60,6 +60,7 @@ fn generate_types_content() -> String {
services::services::config::GitHubConfig::decl(), services::services::config::GitHubConfig::decl(),
services::services::config::SoundFile::decl(), services::services::config::SoundFile::decl(),
services::services::config::UiLanguage::decl(), services::services::config::UiLanguage::decl(),
services::services::config::ShowcaseState::decl(),
services::services::auth::DeviceFlowStartResponse::decl(), services::services::auth::DeviceFlowStartResponse::decl(),
server::routes::auth::DevicePollStatus::decl(), server::routes::auth::DevicePollStatus::decl(),
server::routes::auth::CheckTokenResponse::decl(), server::routes::auth::CheckTokenResponse::decl(),

View File

@@ -22,6 +22,7 @@ pub type SoundFile = versions::v7::SoundFile;
pub type EditorType = versions::v7::EditorType; pub type EditorType = versions::v7::EditorType;
pub type GitHubConfig = versions::v7::GitHubConfig; pub type GitHubConfig = versions::v7::GitHubConfig;
pub type UiLanguage = versions::v7::UiLanguage; pub type UiLanguage = versions::v7::UiLanguage;
pub type ShowcaseState = versions::v7::ShowcaseState;
/// Will always return config, trying old schemas or eventually returning default /// Will always return config, trying old schemas or eventually returning default
pub async fn load_config_from_file(config_path: &PathBuf) -> Config { pub async fn load_config_from_file(config_path: &PathBuf) -> Config {

View File

@@ -11,6 +11,12 @@ fn default_git_branch_prefix() -> String {
"vk".to_string() "vk".to_string()
} }
#[derive(Clone, Debug, Serialize, Deserialize, TS, Default)]
pub struct ShowcaseState {
#[serde(default)]
pub seen_features: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)]
#[ts(use_ts_enum)] #[ts(use_ts_enum)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -41,6 +47,8 @@ pub struct Config {
pub language: UiLanguage, pub language: UiLanguage,
#[serde(default = "default_git_branch_prefix")] #[serde(default = "default_git_branch_prefix")]
pub git_branch_prefix: String, pub git_branch_prefix: String,
#[serde(default)]
pub showcases: ShowcaseState,
} }
impl Config { impl Config {
@@ -90,6 +98,7 @@ impl Config {
show_release_notes: old_config.show_release_notes, show_release_notes: old_config.show_release_notes,
language: old_config.language, language: old_config.language,
git_branch_prefix: default_git_branch_prefix(), git_branch_prefix: default_git_branch_prefix(),
showcases: ShowcaseState::default(),
}) })
} }
} }
@@ -134,6 +143,7 @@ impl Default for Config {
show_release_notes: false, show_release_notes: false,
language: UiLanguage::default(), language: UiLanguage::default(),
git_branch_prefix: default_git_branch_prefix(), git_branch_prefix: default_git_branch_prefix(),
showcases: ShowcaseState::default(),
} }
} }
} }

View File

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

View File

@@ -1,44 +1,41 @@
import { ShowcaseConfig } from '@/types/showcase'; 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 = { 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 { useState, useEffect, useCallback, useRef } from 'react';
import type { ShowcaseConfig } from '@/types/showcase'; import type { ShowcaseConfig } from '@/types/showcase';
import { hasSeen as hasSeenUtil, markSeen } from '@/utils/showcasePersistence'; import { useShowcasePersistence } from './useShowcasePersistence';
export interface ShowcaseTriggerOptions { export interface ShowcaseTriggerOptions {
enabled: boolean; enabled: boolean;
@@ -27,17 +27,17 @@ export function useShowcaseTrigger(
markSeenOnClose = true, markSeenOnClose = true,
} = options; } = options;
const persistence = useShowcasePersistence();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [hasSeen, setHasSeen] = useState(() => const [hasSeenState, setHasSeenState] = useState(false);
hasSeenUtil(config.id, config.version)
);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
// Keep 'hasSeen' in sync if id/version change // Keep 'hasSeenState' in sync if id change or config loads
useEffect(() => { useEffect(() => {
setHasSeen(hasSeenUtil(config.id, config.version)); if (!persistence.isLoaded) return;
}, [config.id, config.version]); setHasSeenState(persistence.hasSeen(config.id));
}, [persistence.isLoaded, config.id, persistence]);
// Cleanup timers // Cleanup timers
useEffect(() => { useEffect(() => {
@@ -53,9 +53,11 @@ export function useShowcaseTrigger(
// Handle enabled state changes // Handle enabled state changes
useEffect(() => { useEffect(() => {
if (!persistence.isLoaded) return;
if (enabled) { if (enabled) {
// Only show if not seen // Only show if not seen
if (!hasSeen) { if (!hasSeenState) {
// Clear any existing timer // Clear any existing timer
if (timerRef.current !== null) { if (timerRef.current !== null) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
@@ -87,7 +89,7 @@ export function useShowcaseTrigger(
timerRef.current = null; timerRef.current = null;
} }
}; };
}, [enabled, hasSeen, openDelay, resetOnDisable]); }, [persistence.isLoaded, enabled, hasSeenState, openDelay, resetOnDisable]);
const open = useCallback(() => { const open = useCallback(() => {
setIsOpen(true); setIsOpen(true);
@@ -95,15 +97,15 @@ export function useShowcaseTrigger(
const close = useCallback(() => { const close = useCallback(() => {
if (markSeenOnClose) { if (markSeenOnClose) {
markSeen(config.id, config.version); persistence.markSeen(config.id);
setHasSeen(true); setHasSeenState(true);
} }
if (timerRef.current !== null) { if (timerRef.current !== null) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
timerRef.current = null; timerRef.current = null;
} }
setIsOpen(false); 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 type { GitBranch } from 'shared/types';
import { openTaskForm } from '@/lib/openTaskForm'; import { openTaskForm } from '@/lib/openTaskForm';
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal'; import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
import { taskPanelShowcase } from '@/config/showcases'; import { showcases } from '@/config/showcases';
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger'; import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
import { useSearch } from '@/contexts/search-context'; import { useSearch } from '@/contexts/search-context';
@@ -157,7 +157,7 @@ export function ProjectTasks() {
const isPanelOpen = Boolean(taskId && selectedTask); const isPanelOpen = Boolean(taskId && selectedTask);
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } = const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
useShowcaseTrigger(taskPanelShowcase, { useShowcaseTrigger(showcases.taskPanel, {
enabled: isPanelOpen, enabled: isPanelOpen,
}); });
@@ -766,7 +766,7 @@ export function ProjectTasks() {
<FeatureShowcaseModal <FeatureShowcaseModal
isOpen={showTaskPanelShowcase} isOpen={showTaskPanelShowcase}
onClose={closeTaskPanelShowcase} onClose={closeTaskPanelShowcase}
config={taskPanelShowcase} config={showcases.taskPanel}
/> />
</div> </div>
); );

View File

@@ -13,6 +13,5 @@ export interface ShowcaseStage {
export interface ShowcaseConfig { export interface ShowcaseConfig {
id: string; id: string;
version: number;
stages: ShowcaseStage[]; 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');
}

View File

@@ -92,7 +92,7 @@ export type ImageResponse = { id: string, file_path: string, original_name: stri
export enum GitHubServiceError { TOKEN_INVALID = "TOKEN_INVALID", INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", REPO_NOT_FOUND_OR_NO_ACCESS = "REPO_NOT_FOUND_OR_NO_ACCESS" } export enum GitHubServiceError { TOKEN_INVALID = "TOKEN_INVALID", INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS", REPO_NOT_FOUND_OR_NO_ACCESS = "REPO_NOT_FOUND_OR_NO_ACCESS" }
export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, github_login_acknowledged: boolean, telemetry_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, }; export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, github_login_acknowledged: boolean, telemetry_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean | null, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, };
export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, }; export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, };
@@ -108,6 +108,8 @@ export enum SoundFile { ABSTRACT_SOUND1 = "ABSTRACT_SOUND1", ABSTRACT_SOUND2 = "
export type UiLanguage = "BROWSER" | "EN" | "JA" | "ES" | "KO"; export type UiLanguage = "BROWSER" | "EN" | "JA" | "ES" | "KO";
export type ShowcaseState = { seen_features: Array<string>, };
export type DeviceFlowStartResponse = { user_code: string, verification_uri: string, expires_in: number, interval: number, }; export type DeviceFlowStartResponse = { user_code: string, verification_uri: string, expires_in: number, interval: number, };
export enum DevicePollStatus { SLOW_DOWN = "SLOW_DOWN", AUTHORIZATION_PENDING = "AUTHORIZATION_PENDING", SUCCESS = "SUCCESS" } export enum DevicePollStatus { SLOW_DOWN = "SLOW_DOWN", AUTHORIZATION_PENDING = "AUTHORIZATION_PENDING", SUCCESS = "SUCCESS" }