Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/workspaces/PersonalizedContent.tsx
2500 views
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;
262
263