Skip to content

Commit c608482

Browse files
Siddhant-K-codefiliptronicekloujaybee
authored
[PAYG - dashboard]: Implement Personalised content selection for users based on profile (#20034)
* 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 <[email protected]> * Fix svg method in light/dark mode * more copy fixes Co-authored-by: Lou Bichard <[email protected]> --------- Co-authored-by: Filip Troníček <[email protected]> Co-authored-by: Lou Bichard <[email protected]>
1 parent e37bd00 commit c608482

File tree

3 files changed

+265
-41
lines changed

3 files changed

+265
-41
lines changed

components/dashboard/src/icons/gitpod-stroked.svg

Lines changed: 2 additions & 9 deletions
Loading
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import React, { useEffect, useState } from "react";
8+
import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
9+
import { useCurrentUser } from "../user-context";
10+
import { storageAvailable } from "../utils";
11+
import { Heading3 } from "@podkit/typography/Headings";
12+
13+
type ContentItem = {
14+
url: string;
15+
title: string;
16+
label: string;
17+
priority?: number;
18+
recommended?: {
19+
jobRole?: string[];
20+
explorationReasons?: string[];
21+
signupGoals?: string[];
22+
};
23+
};
24+
25+
const contentList: ContentItem[] = [
26+
{
27+
url: "https://www.gitpod.io/blog/writing-software-with-chopsticks-an-intro-to-vdi",
28+
title: "Why replace a VDI with Gitpod",
29+
label: "vdi-replacement",
30+
priority: 1,
31+
recommended: {
32+
explorationReasons: ["replace-remote-dev"],
33+
signupGoals: ["efficiency-collab", "security"],
34+
},
35+
},
36+
{
37+
url: "https://www.gitpod.io/customers/luminus",
38+
title: "Solve python dependency issues with Gitpod",
39+
label: "luminus-case-study",
40+
priority: 2,
41+
recommended: {
42+
jobRole: ["data"],
43+
},
44+
},
45+
{
46+
url: "https://www.gitpod.io/blog/how-to-use-vdis-and-cdes-together",
47+
title: "Using VDIs and Gitpod together",
48+
label: "vdi-and-cde",
49+
priority: 3,
50+
recommended: {
51+
explorationReasons: ["replace-remote-dev"],
52+
signupGoals: ["security"],
53+
},
54+
},
55+
{
56+
url: "https://www.gitpod.io/blog/onboard-contractors-securely-and-quickly-using-gitpod",
57+
title: "Onboard contractors securely with Gitpod",
58+
label: "onboard-contractors",
59+
priority: 4,
60+
recommended: {
61+
jobRole: ["enabling", "team-lead"],
62+
signupGoals: ["onboarding", "security"],
63+
},
64+
},
65+
{
66+
url: "https://www.gitpod.io/solutions/onboarding",
67+
title: "Onboard developers in one click with Gitpod",
68+
label: "onboarding-solutions",
69+
priority: 5,
70+
recommended: {
71+
signupGoals: ["onboarding", "efficiency-collab"],
72+
},
73+
},
74+
{
75+
url: "https://www.gitpod.io/customers/kingland",
76+
title: "The impact of Gitpod on supply-chain security",
77+
label: "kingland-case-study",
78+
priority: 6,
79+
recommended: {
80+
signupGoals: ["security"],
81+
},
82+
},
83+
{
84+
url: "https://www.gitpod.io/blog/improve-security-using-ephemeral-development-environments",
85+
title: "Improve security with ephemeral environments",
86+
label: "ephemeral-security",
87+
priority: 7,
88+
recommended: {
89+
signupGoals: ["security"],
90+
},
91+
},
92+
{
93+
url: "https://www.gitpod.io/blog/using-a-cde-roi-calculator",
94+
title: "What is the business case for a CDE",
95+
label: "cde-roi-calculator",
96+
priority: 8,
97+
recommended: {
98+
jobRole: ["enabling", "team-lead"],
99+
explorationReasons: ["replace-remote-dev"],
100+
signupGoals: ["efficiency-collab", "security"],
101+
},
102+
},
103+
{
104+
url: "https://www.gitpod.io/blog/whats-a-cloud-development-environment",
105+
title: "What is a cloud development environment",
106+
label: "what-is-cde",
107+
priority: 9,
108+
recommended: {
109+
jobRole: ["enabling", "team-lead"],
110+
},
111+
},
112+
];
113+
114+
const defaultContent: ContentItem[] = [
115+
{
116+
url: "https://www.gitpod.io/blog/whats-a-cloud-development-environment",
117+
title: "What's a CDE",
118+
label: "what-is-cde",
119+
},
120+
{
121+
url: "https://www.gitpod.io/solutions/onboarding",
122+
title: "Onboarding developers in one click",
123+
label: "onboarding-solutions",
124+
},
125+
{
126+
url: "https://www.gitpod.io/blog/using-a-cde-roi-calculator",
127+
title: "Building a business case for Gitpod",
128+
label: "cde-roi-calculator",
129+
},
130+
];
131+
132+
const PersonalizedContent: React.FC = () => {
133+
const user = useCurrentUser();
134+
const [selectedContent, setSelectedContent] = useState<ContentItem[]>([]);
135+
136+
useEffect(() => {
137+
if (!storageAvailable("localStorage")) {
138+
// Handle the case where localStorage is not available
139+
setSelectedContent(getFirstWeekContent(user));
140+
return;
141+
}
142+
143+
let content: ContentItem[] = [];
144+
let lastShownContent: string[] = [];
145+
146+
try {
147+
const storedContentData = localStorage.getItem("personalized-content-data");
148+
const currentTime = new Date().getTime();
149+
150+
if (storedContentData) {
151+
const { lastTime, lastContent } = JSON.parse(storedContentData);
152+
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
153+
const weeksPassed = Math.floor((currentTime - lastTime) / WEEK_IN_MS);
154+
lastShownContent = lastContent || [];
155+
156+
if (weeksPassed >= 1) {
157+
content = getRandomContent(contentList, 3, lastShownContent);
158+
} else {
159+
content = getFirstWeekContent(user);
160+
}
161+
} else {
162+
content = getFirstWeekContent(user);
163+
}
164+
165+
localStorage.setItem(
166+
"personalized-content-data",
167+
JSON.stringify({
168+
lastContent: content.map((item) => item.label),
169+
lastTime: currentTime,
170+
}),
171+
);
172+
173+
setSelectedContent(content);
174+
} catch (error) {
175+
console.error("Error handling personalized content: ", error);
176+
setSelectedContent(getRandomContent(contentList, 3, []));
177+
}
178+
}, [user]);
179+
180+
return (
181+
<div className="flex flex-col gap-2">
182+
<Heading3> Personalised for you </Heading3>
183+
<div className="flex flex-col gap-1 w-fit">
184+
{selectedContent.map((item, index) => (
185+
<a
186+
key={index}
187+
href={item.url}
188+
target="_blank"
189+
rel="noopener noreferrer"
190+
className="text-sm text-pk-content-primary items-center hover:text-blue-600 dark:hover:text-blue-400"
191+
>
192+
{item.title}
193+
</a>
194+
))}
195+
</div>
196+
</div>
197+
);
198+
};
199+
200+
/**
201+
* Content Selection Logic:
202+
*
203+
* 1. Filter contentList based on user profile:
204+
* - Match jobRole if specified
205+
* - Match any explorationReasons if specified
206+
* - Match any signupGoals if specified
207+
* 2. Sort matched content by priority (lower number = higher priority)
208+
* 3. Select top 3 items from matched content
209+
* 4. If less than 3 items selected:
210+
* - Fill remaining slots with unique items from defaultContent
211+
* 5. If no matches found:
212+
* - Show default content
213+
*
214+
* After Week 1:
215+
* - Show random 3 articles from the entire content list
216+
* - Avoid repeating content shown in the previous week
217+
* - Update content weekly
218+
*/
219+
220+
function getFirstWeekContent(user: User | undefined): ContentItem[] {
221+
if (!user?.profile) return defaultContent;
222+
223+
const { explorationReasons, signupGoals, jobRole } = user.profile;
224+
225+
const matchingContent = contentList.filter((item) => {
226+
const rec = item.recommended;
227+
if (!rec) return false;
228+
229+
const jobRoleMatch = !rec.jobRole || rec.jobRole.includes(jobRole);
230+
const reasonsMatch =
231+
!rec.explorationReasons || rec.explorationReasons.some((r) => explorationReasons?.includes(r));
232+
const goalsMatch = !rec.signupGoals || rec.signupGoals.some((g) => signupGoals?.includes(g));
233+
234+
return jobRoleMatch && reasonsMatch && goalsMatch;
235+
});
236+
237+
const sortedContent = matchingContent.sort((a, b) => (a.priority || Infinity) - (b.priority || Infinity));
238+
239+
let selectedContent = sortedContent.slice(0, 3);
240+
241+
if (selectedContent.length < 3) {
242+
const remainingCount = 3 - selectedContent.length;
243+
const selectedLabels = new Set(selectedContent.map((item) => item.label));
244+
245+
const additionalContent = defaultContent
246+
.filter((item) => !selectedLabels.has(item.label))
247+
.slice(0, remainingCount);
248+
249+
selectedContent = [...selectedContent, ...additionalContent];
250+
}
251+
252+
return selectedContent;
253+
}
254+
255+
function getRandomContent(list: ContentItem[], count: number, lastShown: string[]): ContentItem[] {
256+
const availableContent = list.filter((item) => !lastShown.includes(item.label));
257+
const shuffled = availableContent.length >= count ? availableContent : list;
258+
return [...shuffled].sort(() => 0.5 - Math.random()).slice(0, count);
259+
}
260+
261+
export default PersonalizedContent;

components/dashboard/src/workspaces/Workspaces.tsx

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { BlogBanners } from "./BlogBanners";
2323
import { BookOpen, Code } from "lucide-react";
2424
import { ReactComponent as GitpodStrokedSVG } from "../icons/gitpod-stroked.svg";
2525
import { isGitpodIo } from "../utils";
26+
import PersonalizedContent from "./PersonalizedContent";
2627

2728
const WorkspacesPage: FunctionComponent = () => {
2829
const [limit, setLimit] = useState(50);
@@ -217,38 +218,7 @@ const WorkspacesPage: FunctionComponent = () => {
217218
</a>
218219
</div>
219220
</div>
220-
{/* TODO: Create this section based on user submissions while onboarding form */}
221-
<div className="flex flex-col gap-2">
222-
<h3 className="text-lg font-semibold text-pk-content-primary">
223-
Personalised for you
224-
</h3>
225-
<div className="flex flex-col gap-1 w-fit">
226-
<a
227-
href="https://www.gitpod.io/blog/whats-a-cloud-development-environment"
228-
target="_blank"
229-
rel="noopener noreferrer"
230-
className="text-sm text-pk-content-primary items-center hover:text-blue-600 dark:hover:text-blue-400"
231-
>
232-
What's a CDE
233-
</a>
234-
<a
235-
href="https://www.gitpod.io/solutions/onboarding"
236-
target="_blank"
237-
rel="noopener noreferrer"
238-
className="text-sm text-pk-content-primary items-center hover:text-blue-600 dark:hover:text-blue-400"
239-
>
240-
Onboard developers in one click
241-
</a>
242-
<a
243-
href="https://www.gitpod.io/blog/using-a-cde-roi-calculator"
244-
target="_blank"
245-
rel="noopener noreferrer"
246-
className="text-sm text-pk-content-primary items-center hover:text-blue-600 dark:hover:text-blue-400"
247-
>
248-
Building a business case for Gitpod
249-
</a>
250-
</div>
251-
</div>
221+
<PersonalizedContent />
252222
<BlogBanners />
253223
</div>
254224
)}

0 commit comments

Comments
 (0)