From c608482419fd84ba798e6262172ee2429e1c7803 Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Mon, 15 Jul 2024 16:34:54 +0530 Subject: [PATCH] [PAYG - dashboard]: Implement Personalised content selection for users based on profile (#20034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial work * more cleanup & improv. * Improve logic for content rendering * remove extra check of working professional * chore: Handle case where localStorage is not available in PersonalizedContent component * remove additional type safety check * use content while pushing first week data * Introduce new content recommendation system * Fill remaining slots with unique items from defaultContent * Update to use Podkit design H3 * copy text updates Co-authored-by: Lou Bichard * Fix svg method in light/dark mode * more copy fixes Co-authored-by: Lou Bichard --------- Co-authored-by: Filip Troníček Co-authored-by: Lou Bichard --- .../dashboard/src/icons/gitpod-stroked.svg | 11 +- .../src/workspaces/PersonalizedContent.tsx | 261 ++++++++++++++++++ .../dashboard/src/workspaces/Workspaces.tsx | 34 +-- 3 files changed, 265 insertions(+), 41 deletions(-) create mode 100644 components/dashboard/src/workspaces/PersonalizedContent.tsx diff --git a/components/dashboard/src/icons/gitpod-stroked.svg b/components/dashboard/src/icons/gitpod-stroked.svg index 6e4c9708cab000..79597241309cec 100644 --- a/components/dashboard/src/icons/gitpod-stroked.svg +++ b/components/dashboard/src/icons/gitpod-stroked.svg @@ -1,12 +1,5 @@ - - + { + const user = useCurrentUser(); + const [selectedContent, setSelectedContent] = useState([]); + + useEffect(() => { + if (!storageAvailable("localStorage")) { + // Handle the case where localStorage is not available + setSelectedContent(getFirstWeekContent(user)); + return; + } + + let content: ContentItem[] = []; + let lastShownContent: string[] = []; + + try { + const storedContentData = localStorage.getItem("personalized-content-data"); + const currentTime = new Date().getTime(); + + if (storedContentData) { + const { lastTime, lastContent } = JSON.parse(storedContentData); + const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; + const weeksPassed = Math.floor((currentTime - lastTime) / WEEK_IN_MS); + lastShownContent = lastContent || []; + + if (weeksPassed >= 1) { + content = getRandomContent(contentList, 3, lastShownContent); + } else { + content = getFirstWeekContent(user); + } + } else { + content = getFirstWeekContent(user); + } + + localStorage.setItem( + "personalized-content-data", + JSON.stringify({ + lastContent: content.map((item) => item.label), + lastTime: currentTime, + }), + ); + + setSelectedContent(content); + } catch (error) { + console.error("Error handling personalized content: ", error); + setSelectedContent(getRandomContent(contentList, 3, [])); + } + }, [user]); + + return ( +
+ Personalised for you +
+ {selectedContent.map((item, index) => ( + + {item.title} + + ))} +
+
+ ); +}; + +/** + * Content Selection Logic: + * + * 1. Filter contentList based on user profile: + * - Match jobRole if specified + * - Match any explorationReasons if specified + * - Match any signupGoals if specified + * 2. Sort matched content by priority (lower number = higher priority) + * 3. Select top 3 items from matched content + * 4. If less than 3 items selected: + * - Fill remaining slots with unique items from defaultContent + * 5. If no matches found: + * - Show default content + * + * After Week 1: + * - Show random 3 articles from the entire content list + * - Avoid repeating content shown in the previous week + * - Update content weekly + */ + +function getFirstWeekContent(user: User | undefined): ContentItem[] { + if (!user?.profile) return defaultContent; + + const { explorationReasons, signupGoals, jobRole } = user.profile; + + const matchingContent = contentList.filter((item) => { + const rec = item.recommended; + if (!rec) return false; + + const jobRoleMatch = !rec.jobRole || rec.jobRole.includes(jobRole); + const reasonsMatch = + !rec.explorationReasons || rec.explorationReasons.some((r) => explorationReasons?.includes(r)); + const goalsMatch = !rec.signupGoals || rec.signupGoals.some((g) => signupGoals?.includes(g)); + + return jobRoleMatch && reasonsMatch && goalsMatch; + }); + + const sortedContent = matchingContent.sort((a, b) => (a.priority || Infinity) - (b.priority || Infinity)); + + let selectedContent = sortedContent.slice(0, 3); + + if (selectedContent.length < 3) { + const remainingCount = 3 - selectedContent.length; + const selectedLabels = new Set(selectedContent.map((item) => item.label)); + + const additionalContent = defaultContent + .filter((item) => !selectedLabels.has(item.label)) + .slice(0, remainingCount); + + selectedContent = [...selectedContent, ...additionalContent]; + } + + return selectedContent; +} + +function getRandomContent(list: ContentItem[], count: number, lastShown: string[]): ContentItem[] { + const availableContent = list.filter((item) => !lastShown.includes(item.label)); + const shuffled = availableContent.length >= count ? availableContent : list; + return [...shuffled].sort(() => 0.5 - Math.random()).slice(0, count); +} + +export default PersonalizedContent; diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 735b06c7ad31f6..0671c8c57e9841 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -23,6 +23,7 @@ import { BlogBanners } from "./BlogBanners"; import { BookOpen, Code } from "lucide-react"; import { ReactComponent as GitpodStrokedSVG } from "../icons/gitpod-stroked.svg"; import { isGitpodIo } from "../utils"; +import PersonalizedContent from "./PersonalizedContent"; const WorkspacesPage: FunctionComponent = () => { const [limit, setLimit] = useState(50); @@ -217,38 +218,7 @@ const WorkspacesPage: FunctionComponent = () => { - {/* TODO: Create this section based on user submissions while onboarding form */} - + )}