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:
committed by
GitHub
parent
e4a4c004da
commit
50d285cfe5
@@ -60,6 +60,7 @@ fn generate_types_content() -> String {
|
||||
services::services::config::GitHubConfig::decl(),
|
||||
services::services::config::SoundFile::decl(),
|
||||
services::services::config::UiLanguage::decl(),
|
||||
services::services::config::ShowcaseState::decl(),
|
||||
services::services::auth::DeviceFlowStartResponse::decl(),
|
||||
server::routes::auth::DevicePollStatus::decl(),
|
||||
server::routes::auth::CheckTokenResponse::decl(),
|
||||
|
||||
@@ -22,6 +22,7 @@ pub type SoundFile = versions::v7::SoundFile;
|
||||
pub type EditorType = versions::v7::EditorType;
|
||||
pub type GitHubConfig = versions::v7::GitHubConfig;
|
||||
pub type UiLanguage = versions::v7::UiLanguage;
|
||||
pub type ShowcaseState = versions::v7::ShowcaseState;
|
||||
|
||||
/// Will always return config, trying old schemas or eventually returning default
|
||||
pub async fn load_config_from_file(config_path: &PathBuf) -> Config {
|
||||
|
||||
@@ -11,6 +11,12 @@ fn default_git_branch_prefix() -> 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)]
|
||||
#[ts(use_ts_enum)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
@@ -41,6 +47,8 @@ pub struct Config {
|
||||
pub language: UiLanguage,
|
||||
#[serde(default = "default_git_branch_prefix")]
|
||||
pub git_branch_prefix: String,
|
||||
#[serde(default)]
|
||||
pub showcases: ShowcaseState,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -90,6 +98,7 @@ impl Config {
|
||||
show_release_notes: old_config.show_release_notes,
|
||||
language: old_config.language,
|
||||
git_branch_prefix: default_git_branch_prefix(),
|
||||
showcases: ShowcaseState::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -134,6 +143,7 @@ impl Default for Config {
|
||||
show_release_notes: false,
|
||||
language: UiLanguage::default(),
|
||||
git_branch_prefix: default_git_branch_prefix(),
|
||||
showcases: ShowcaseState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
frontend/src/hooks/useShowcasePersistence.ts
Normal file
42
frontend/src/hooks/useShowcasePersistence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,5 @@ export interface ShowcaseStage {
|
||||
|
||||
export interface ShowcaseConfig {
|
||||
id: string;
|
||||
version: number;
|
||||
stages: ShowcaseStage[];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 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, };
|
||||
|
||||
@@ -108,6 +108,8 @@ export enum SoundFile { ABSTRACT_SOUND1 = "ABSTRACT_SOUND1", ABSTRACT_SOUND2 = "
|
||||
|
||||
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 enum DevicePollStatus { SLOW_DOWN = "SLOW_DOWN", AUTHORIZATION_PENDING = "AUTHORIZATION_PENDING", SUCCESS = "SUCCESS" }
|
||||
|
||||
Reference in New Issue
Block a user