Path: blob/main/components/dashboard/src/workspaces/PersonalizedContent.tsx
2500 views
/**1* Copyright (c) 2024 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import React, { useEffect, useState } from "react";7import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb";8import { useCurrentUser } from "../user-context";9import { storageAvailable } from "../utils";10import { Heading3 } from "@podkit/typography/Headings";1112type ContentItem = {13url: string;14title: string;15label: string;16priority?: number;17recommended?: {18jobRole?: string[];19explorationReasons?: string[];20signupGoals?: string[];21};22};2324const contentList: ContentItem[] = [25{26url: "https://www.gitpod.io/blog/writing-software-with-chopsticks-an-intro-to-vdi",27title: "Why replace a VDI with Gitpod",28label: "vdi-replacement",29priority: 1,30recommended: {31explorationReasons: ["replace-remote-dev"],32signupGoals: ["efficiency-collab", "security"],33},34},35{36url: "https://www.gitpod.io/customers/luminus",37title: "Solve python dependency issues with Gitpod",38label: "luminus-case-study",39priority: 2,40recommended: {41jobRole: ["data"],42},43},44{45url: "https://www.gitpod.io/blog/how-to-use-vdis-and-cdes-together",46title: "Using VDIs and Gitpod together",47label: "vdi-and-cde",48priority: 3,49recommended: {50explorationReasons: ["replace-remote-dev"],51signupGoals: ["security"],52},53},54{55url: "https://www.gitpod.io/blog/onboard-contractors-securely-and-quickly-using-gitpod",56title: "Onboard contractors securely with Gitpod",57label: "onboard-contractors",58priority: 4,59recommended: {60jobRole: ["enabling", "team-lead"],61signupGoals: ["onboarding", "security"],62},63},64{65url: "https://www.gitpod.io/solutions/onboarding",66title: "Onboard developers in one click with Gitpod",67label: "onboarding-solutions",68priority: 5,69recommended: {70signupGoals: ["onboarding", "efficiency-collab"],71},72},73{74url: "https://www.gitpod.io/customers/kingland",75title: "The impact of Gitpod on supply-chain security",76label: "kingland-case-study",77priority: 6,78recommended: {79signupGoals: ["security"],80},81},82{83url: "https://www.gitpod.io/blog/improve-security-using-ephemeral-development-environments",84title: "Improve security with ephemeral environments",85label: "ephemeral-security",86priority: 7,87recommended: {88signupGoals: ["security"],89},90},91{92url: "https://www.gitpod.io/blog/using-a-cde-roi-calculator",93title: "What is the business case for a CDE",94label: "cde-roi-calculator",95priority: 8,96recommended: {97jobRole: ["enabling", "team-lead"],98explorationReasons: ["replace-remote-dev"],99signupGoals: ["efficiency-collab", "security"],100},101},102{103url: "https://www.gitpod.io/blog/whats-a-cloud-development-environment",104title: "What is a cloud development environment",105label: "what-is-cde",106priority: 9,107recommended: {108jobRole: ["enabling", "team-lead"],109},110},111];112113const defaultContent: ContentItem[] = [114{115url: "https://www.gitpod.io/blog/whats-a-cloud-development-environment",116title: "What's a CDE",117label: "what-is-cde",118},119{120url: "https://www.gitpod.io/solutions/onboarding",121title: "Onboarding developers in one click",122label: "onboarding-solutions",123},124{125url: "https://www.gitpod.io/blog/using-a-cde-roi-calculator",126title: "Building a business case for Gitpod",127label: "cde-roi-calculator",128},129];130131const PersonalizedContent: React.FC = () => {132const user = useCurrentUser();133const [selectedContent, setSelectedContent] = useState<ContentItem[]>([]);134135useEffect(() => {136if (!storageAvailable("localStorage")) {137// Handle the case where localStorage is not available138setSelectedContent(getFirstWeekContent(user));139return;140}141142let content: ContentItem[] = [];143let lastShownContent: string[] = [];144145try {146const storedContentData = localStorage.getItem("personalized-content-data");147const currentTime = new Date().getTime();148149if (storedContentData) {150const { lastTime, lastContent } = JSON.parse(storedContentData);151const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;152const weeksPassed = Math.floor((currentTime - lastTime) / WEEK_IN_MS);153lastShownContent = lastContent || [];154155if (weeksPassed >= 1) {156content = getRandomContent(contentList, 3, lastShownContent);157} else {158content = getFirstWeekContent(user);159}160} else {161content = getFirstWeekContent(user);162}163164localStorage.setItem(165"personalized-content-data",166JSON.stringify({167lastContent: content.map((item) => item.label),168lastTime: currentTime,169}),170);171172setSelectedContent(content);173} catch (error) {174console.error("Error handling personalized content: ", error);175setSelectedContent(getRandomContent(contentList, 3, []));176}177}, [user]);178179return (180<div className="flex flex-col gap-2">181<Heading3> Personalised for you </Heading3>182<div className="flex flex-col gap-1 w-fit">183{selectedContent.map((item, index) => (184<a185key={index}186href={item.url}187target="_blank"188rel="noopener noreferrer"189className="text-sm text-pk-content-primary items-center hover:text-blue-600 dark:hover:text-blue-400"190>191{item.title}192</a>193))}194</div>195</div>196);197};198199/**200* Content Selection Logic:201*202* 1. Filter contentList based on user profile:203* - Match jobRole if specified204* - Match any explorationReasons if specified205* - Match any signupGoals if specified206* 2. Sort matched content by priority (lower number = higher priority)207* 3. Select top 3 items from matched content208* 4. If less than 3 items selected:209* - Fill remaining slots with unique items from defaultContent210* 5. If no matches found:211* - Show default content212*213* After Week 1:214* - Show random 3 articles from the entire content list215* - Avoid repeating content shown in the previous week216* - Update content weekly217*/218219function getFirstWeekContent(user: User | undefined): ContentItem[] {220if (!user?.profile) return defaultContent;221222const { explorationReasons, signupGoals, jobRole } = user.profile;223224const matchingContent = contentList.filter((item) => {225const rec = item.recommended;226if (!rec) return false;227228const jobRoleMatch = !rec.jobRole || rec.jobRole.includes(jobRole);229const reasonsMatch =230!rec.explorationReasons || rec.explorationReasons.some((r) => explorationReasons?.includes(r));231const goalsMatch = !rec.signupGoals || rec.signupGoals.some((g) => signupGoals?.includes(g));232233return jobRoleMatch && reasonsMatch && goalsMatch;234});235236const sortedContent = matchingContent.sort((a, b) => (a.priority || Infinity) - (b.priority || Infinity));237238let selectedContent = sortedContent.slice(0, 3);239240if (selectedContent.length < 3) {241const remainingCount = 3 - selectedContent.length;242const selectedLabels = new Set(selectedContent.map((item) => item.label));243244const additionalContent = defaultContent245.filter((item) => !selectedLabels.has(item.label))246.slice(0, remainingCount);247248selectedContent = [...selectedContent, ...additionalContent];249}250251return selectedContent;252}253254function getRandomContent(list: ContentItem[], count: number, lastShown: string[]): ContentItem[] {255const availableContent = list.filter((item) => !lastShown.includes(item.label));256const shuffled = availableContent.length >= count ? availableContent : list;257return [...shuffled].sort(() => 0.5 - Math.random()).slice(0, count);258}259260export default PersonalizedContent;261262263