feat(client): add daily challenges (#60867)

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Tom
2025-07-28 13:08:10 -05:00
committed by GitHub
parent 2cdd62b00b
commit bde1e6f81b
43 changed files with 1607 additions and 114 deletions

View File

@@ -4770,6 +4770,14 @@
}
}
},
"daily-coding-challenge": {
"title": "Daily Coding Challenge",
"blocks": {
"daily-coding-challenge": {
"title": "Daily Coding Challenge"
}
}
},
"misc-text": {
"browse-other": "Browse our other free certifications",
"courses": "Courses",

View File

@@ -117,7 +117,38 @@
"share-on-bluesky": "Share on BlueSky",
"share-on-threads": "Share on Threads",
"play-scene": "Press Play",
"download-latest-version": "Download the Latest Version"
"download-latest-version": "Download the Latest Version",
"start": "Start",
"go-to-today": "Go to Today's Challenge",
"go-to-today-long": "Go to Today's Coding Challenge",
"go-to-archive": "Go to Archive",
"go-to-archive-long": "Go to Daily Coding Challenge Archive"
},
"daily-coding-challenges": {
"title": "Daily Coding Challenges",
"map-title": "Try the coding challenge of the day:",
"not-found": "Daily Coding Challenge Not Found.",
"release-note": "New challenges are released at midnight US Central time."
},
"weekdays": {
"short": {
"sunday": "S",
"monday": "M",
"tuesday": "T",
"wednesday": "W",
"thursday": "T",
"friday": "F",
"saturday": "S"
},
"long": {
"sunday": "Sunday",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday"
}
},
"landing": {
"big-heading-1": "Learn to code — for free.",
@@ -851,6 +882,8 @@
"github": "Link to {{username}}'s GitHub",
"website": "Link to {{username}}'s website",
"twitter": "Link to {{username}}'s Twitter",
"next-month": "Go to next month",
"previous-month": "Go to previous month",
"first-page": "Go to first page",
"previous-page": "Go to previous page",
"next-page": "Go to next page",
@@ -880,7 +913,8 @@
"editor-a11y-off-non-macos": "{{editorName}} editor content. Press Alt+F1 for accessibility options.",
"editor-a11y-on-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Command+E to disable or press Option+F1 for more options.",
"editor-a11y-on-non-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Ctrl+E to disable or press Alt+F1 for more options.",
"terminal-output": "Terminal output"
"terminal-output": "Terminal output",
"not-available": "Not available"
},
"flash": {
"no-email-in-userinfo": "We could not retrieve an email from your chosen provider. Please try another provider or use the 'Continue with Email' option.",

View File

@@ -0,0 +1,21 @@
import React from 'react';
function CalendarIcon(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
return (
<svg
aria-hidden='true'
viewBox='0 0 448 512'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M128 0c17.7 0 32 14.3 32 32l0 32 128 0 0-32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 32 48 0c26.5 0 48 21.5 48 48l0 48L0 160l0-48C0 85.5 21.5 64 48 64l48 0 0-32c0-17.7 14.3-32 32-32zM0 192l448 0 0 272c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 192zm64 80l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm128 0l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm144-16c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zM64 400l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zm144-16c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zm112 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16z' />
</svg>
);
}
CalendarIcon.displayName = 'CalendarIcon';
export default CalendarIcon;

View File

@@ -0,0 +1,21 @@
import React from 'react';
function DailyCodingChallengeIcon(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
return (
<svg
aria-hidden='true'
viewBox='0 0 512 512'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M152.1 38.2c9.9 8.9 10.7 24 1.8 33.9l-72 80c-4.4 4.9-10.6 7.8-17.2 7.9s-12.9-2.4-17.6-7L7 113C-2.3 103.6-2.3 88.4 7 79s24.6-9.4 33.9 0l22.1 22.1 55.1-61.2c8.9-9.9 24-10.7 33.9-1.8zm0 160c9.9 8.9 10.7 24 1.8 33.9l-72 80c-4.4 4.9-10.6 7.8-17.2 7.9s-12.9-2.4-17.6-7L7 273c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l22.1 22.1 55.1-61.2c8.9-9.9 24-10.7 33.9-1.8zM224 96c0-17.7 14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32zm0 160c0-17.7 14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32zM160 416c0-17.7 14.3-32 32-32l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32zM48 368a48 48 0 1 1 0 96 48 48 0 1 1 0-96z' />
</svg>
);
}
DailyCodingChallengeIcon.displayName = 'DailyCodingChallengeIcon';
export default DailyCodingChallengeIcon;

View File

@@ -12,7 +12,11 @@ import {
import { SuperBlockIcon } from '../../assets/superblock-icon';
import LinkButton from '../../assets/icons/link-button';
import { ButtonLink } from '../helpers';
import { showUpcomingChanges } from '../../../config/env.json';
import {
showUpcomingChanges,
showDailyCodingChallenges
} from '../../../config/env.json';
import DailyCodingChallengeWidget from '../daily-coding-challenge/widget';
import './map.css';
interface MapProps {
@@ -82,6 +86,16 @@ function Map({ forLanding = false }: MapProps) {
return (
<Fragment key={stage}>
{
/* Show the daily coding challenge before the "English" curriculum */
showDailyCodingChallenges &&
stage === SuperBlockStage.English && (
<>
<DailyCodingChallengeWidget forLanding={forLanding} />
<Spacer size='m' />
</>
)
}
<h2 className={forLanding ? 'big-heading' : ''}>
{t(superBlockHeadings[stage])}
</h2>

View File

@@ -41,9 +41,16 @@ function ProgressInner({
const [shownPercent, setShownPercent] = useState(0);
const [lastShownPercent, setLastShownPercent] = useState(0);
const progressInnerWrap = useRef<HTMLDivElement>(null);
const intervalRef = useRef<number | null>(null);
const isProgressInViewport = useIsInViewport(progressInnerWrap);
const animateProgressInner = (completedPercent: number) => {
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (completedPercent > 100) completedPercent = 100;
if (completedPercent < 0) completedPercent = 0;
@@ -51,7 +58,7 @@ function ProgressInner({
const intervalsToFinish = transitionLength / intervalLength;
const amountPerInterval = completedPercent / intervalsToFinish;
const myInterval = window.setInterval(() => {
intervalRef.current = window.setInterval(() => {
percent += amountPerInterval;
if (percent > completedPercent) percent = completedPercent;
@@ -61,10 +68,14 @@ function ProgressInner({
);
if (percent >= completedPercent) {
percent = 0;
clearInterval(myInterval);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
}, intervalLength);
};
useEffect(() => {
if (lastShownPercent !== completedPercent && isProgressInViewport) {
setLastShownPercent(completedPercent);
@@ -73,6 +84,16 @@ function ProgressInner({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isProgressInViewport]);
// Cleanup interval on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []);
return (
<>
<div className='completion-block-name'>{title}</div>

View File

@@ -14,6 +14,11 @@ import {
import { liveCerts } from '../../../config/cert-and-project-map';
import { updateAllChallengesInfo } from '../../redux/actions';
import { CertificateNode, ChallengeNode } from '../../redux/prop-types';
import { getIsDailyCodingChallenge } from '../../../../shared/config/challenge-types';
import {
isValidDateParam,
formatDisplayDate
} from '../daily-coding-challenge/helpers';
import ProgressInner from './progress-inner';
const mapStateToProps = createSelector(
@@ -24,10 +29,12 @@ const mapStateToProps = createSelector(
(
currentBlockIds: string[],
{
challengeType,
id,
block,
superBlock
}: {
challengeType: number;
id: string;
block: string;
superBlock: string;
@@ -36,6 +43,7 @@ const mapStateToProps = createSelector(
completedPercent: number
) => ({
currentBlockIds,
challengeType,
id,
block,
superBlock,
@@ -56,17 +64,28 @@ function Progress({
block,
id,
superBlock,
challengeType,
completedChallengesInBlock,
completedPercent,
t,
updateAllChallengesInfo
}: ProgressProps): JSX.Element {
const blockTitle = t(`intro:${superBlock}.blocks.${block}.title`);
let blockTitle = t(`intro:${superBlock}.blocks.${block}.title`);
// Always false for legacy full stack, since it has no projects.
const isCertificationProject = liveCerts.some(cert =>
cert.projects?.some((project: { id: string }) => project.id === id)
);
// Display the date of the challenge in the completion modal for daily challenges
if (getIsDailyCodingChallenge(challengeType)) {
const dateParam =
new URLSearchParams(window.location.search).get('date') || '';
if (isValidDateParam(dateParam)) {
blockTitle += `: ${formatDisplayDate(dateParam)}`;
}
}
const { challengeNodes, certificateNodes } = useGetAllBlockIds();
useEffect(() => {
updateAllChallengesInfo({ challengeNodes, certificateNodes });

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from '../helpers';
import GreenPass from '../../assets/icons/green-pass';
import GreenNotCompleted from '../../assets/icons/green-not-completed';
import { formatDisplayDate } from './helpers';
interface CalendarDayProps {
dayNumber: number;
date?: string;
isCompleted?: boolean;
isAvailable?: boolean;
}
// Todo: Change this to render checkmarks for JS and Python
function DailyCodingChallengeCalendarDay({
dayNumber,
date,
isCompleted = false,
isAvailable = false
}: CalendarDayProps): JSX.Element {
const { t } = useTranslation();
// dayNumber = 0 -> render nothing
if (dayNumber === 0) return <div></div>;
if (!isAvailable)
return (
<button
disabled
className='calendar-day not-available'
aria-label={`${date && formatDisplayDate(date)}, (${t('aria.not-available')})`}
>
<span className='calendar-day-number' aria-hidden='true'>
{dayNumber}
</span>
</button>
);
// isAvailable -> render link to challenge
return (
<Link
to={`/learn/daily-coding-challenge?date=${date}`}
className='calendar-day available'
aria-label={`${date && formatDisplayDate(date)}`}
>
<span className='calendar-day-number' aria-hidden='true'>
{dayNumber}
</span>
{isCompleted ? (
<span className='completed'>
<GreenPass />
</span>
) : (
<span className='not-completed'>
<GreenNotCompleted />
</span>
)}
</Link>
);
}
DailyCodingChallengeCalendarDay.displayName = 'DailyCodingChallengeCalendarDay';
export default DailyCodingChallengeCalendarDay;

View File

@@ -0,0 +1,102 @@
.calendar-weekday-labels {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
}
.calendar-head {
display: flex;
justify-content: center;
align-items: center;
}
.calendar-head h2 {
min-width: 200px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr); /* 7 columns for days of the week */
gap: 4px;
}
.calendar-day {
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 10px;
min-height: 100px;
border: 1px solid var(--tertiary-color);
text-align: center;
background-color: var(--primary-background);
}
.not-available:hover,
.not-available:active {
cursor: not-allowed;
background-color: var(--primary-background);
}
.not-available:hover .calendar-day-number,
.not-available:active .calendar-day-number {
color: var(--primary-color);
}
.available:hover,
.available:active {
cursor: pointer;
background-color: var(--primary-color);
}
.available:hover .calendar-day-number,
.available:active .calendar-day-number {
color: var(--primary-background);
}
.available:hover .completed svg circle,
.available:active .completed svg circle {
fill: var(--primary-background);
stroke: var(--primary-background);
}
.available:hover .not-completed svg circle,
.available:active .not-completed svg circle {
fill: var(--primary-color);
stroke: var(--primary-background);
}
.available:hover svg rect,
.available:active svg rect {
fill: var(--primary-color);
stroke: var(--primary-color);
}
.calendar-day-number {
position: absolute;
top: 0;
left: 5px;
}
.calendar-day svg,
.empty-cirle {
width: calc(10px + 2vw);
height: calc(10px + 2vw);
max-width: 40px;
max-height: 40px;
}
.empty-cirle {
width: calc(10px + 2vw);
height: calc(10px + 2vw);
max-width: 40px;
max-height: 40px;
border-radius: 50%;
border: 2px solid var(--primary-color);
}
@media (max-width: 500px) {
.calendar-day {
min-height: 75px;
}
}

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Callout, Col, Spacer } from '@freecodecamp/ui';
import {
completedDailyCodingChallengesSelector,
isSignedInSelector
} from '../../redux/selectors';
import { CompletedDailyCodingChallenge } from '../../redux/prop-types';
import { Loader } from '../helpers';
import envData from '../../../config/env.json';
import Login from '../Header/components/login';
import CalendarDay from './calendar-day';
import { getTodayUsCentral, formatDate } from './helpers';
import './calendar.css';
import DailyCodingChallengeNotFound from './not-found';
const { apiLocation } = envData;
const mapStateToProps = (state: unknown) => ({
completedDailyCodingChallenges: completedDailyCodingChallengesSelector(
state
) as CompletedDailyCodingChallenge[],
isSignedIn: isSignedInSelector(state)
});
interface DailyCodingChallengeCalendarProps {
completedDailyCodingChallenges: CompletedDailyCodingChallenge[];
isSignedIn: boolean;
}
interface AllDailyChallengeFromDb {
id: string;
date: string;
challengeNumber: number;
title: string;
}
interface DailyChallengeMap {
id: string;
date: string;
isCompleted: boolean;
challengeNumber: number;
title: string;
}
type DailyChallengesMap = Map<string, DailyChallengeMap>;
interface MonthInfo {
days: JSX.Element[];
index: number;
name: string;
year: number;
}
const getMonthInfo = (
year: number,
monthIndex: number,
dailyChallengesMap: DailyChallengesMap
) => {
// Create date for first of the month (handles rollover automatically)
const firstOfMonth = new Date(Date.UTC(year, monthIndex, 1));
const firstOfMonthWeekdayIndex = firstOfMonth.getUTCDay();
const utcYear = firstOfMonth.getUTCFullYear();
const utcMonthIndex = firstOfMonth.getUTCMonth();
// Get number of days in the month (day 0 of next month = last day of current month)
const numberOfDays = new Date(
Date.UTC(utcYear, utcMonthIndex + 1, 0)
).getUTCDate();
const days: JSX.Element[] = [];
// push empty days to before the 1st of the month
for (let i = 0; i < firstOfMonthWeekdayIndex; i++) {
days.push(<CalendarDay key={`empty-${i}`} dayNumber={0} />);
}
for (let day = 1; day <= numberOfDays; day++) {
const formattedDate = formatDate({
month: utcMonthIndex + 1, // Convert back to 1-indexed
day,
year: utcYear
});
const challengeData = dailyChallengesMap.get(formattedDate);
const isCompleted = challengeData?.isCompleted || false;
const isAvailable = challengeData !== undefined;
days.push(
<CalendarDay
key={`day-${day}`}
date={formattedDate}
dayNumber={day}
isCompleted={isCompleted}
isAvailable={isAvailable}
/>
);
}
return {
days,
index: utcMonthIndex,
name: firstOfMonth.toLocaleString('en-US', {
timeZone: 'UTC',
month: 'long'
}),
year: utcYear
};
};
function DailyCodingChallengeCalendar({
completedDailyCodingChallenges,
isSignedIn
}: DailyCodingChallengeCalendarProps): JSX.Element {
const { t } = useTranslation();
const todayUsCentral = getTodayUsCentral();
const completedDailyCodingChallengeIds = completedDailyCodingChallenges.map(
c => c.id
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
const [monthInfo, setMonthInfo] = useState<MonthInfo | null>(null);
const [dailyChallengesMap, setDailyChallengesMap] = useState(
() => new Map<string, DailyChallengeMap>()
);
const fetchChallenges = async () => {
try {
const response = await fetch(`${apiLocation}/daily-coding-challenge/all`);
const challenges = (await response.json()) as AllDailyChallengeFromDb[];
if (Array.isArray(challenges)) {
// Todo: validate shape of challenges
const newDailyChallengesMap = new Map() as DailyChallengesMap;
challenges.forEach(c => {
const date = c.date.split('T')[0];
newDailyChallengesMap.set(date, {
...c,
date,
isCompleted: completedDailyCodingChallengeIds.includes(c.id)
});
});
setDailyChallengesMap(newDailyChallengesMap);
// After getting the challenges and creating the map, set the initial month info -
// Display the month of the current US Central day because challenges are released
// at midnight US Central - so don't show the local month, show the US Central month
const [year, month] = todayUsCentral.split('-').map(Number);
const initialMonthInfo = getMonthInfo(
year,
month - 1, // Convert to 0-indexed month
newDailyChallengesMap
);
setMonthInfo(initialMonthInfo);
} else {
setError(true);
}
} catch (error) {
console.error('Error fetching challenges:', error);
setError(true);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void fetchChallenges();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// we just need to change the month, the year can stay the same
// because it just rolls over, e.g. (index) 12, 2024 will be Jan, 2025
const nextMonth = () => {
setMonthInfo(
m => m && getMonthInfo(m.year, m.index + 1, dailyChallengesMap)
);
};
const prevMonth = () => {
setMonthInfo(
m => m && getMonthInfo(m.year, m.index - 1, dailyChallengesMap)
);
};
const hasOlderChallenges = (
map: DailyChallengesMap,
monthInfo: MonthInfo
): boolean => {
return Array.from(map.keys()).some(dateStr => {
const [year, month] = dateStr.split('-').map(Number);
return (
year < monthInfo.year ||
(year === monthInfo.year && month - 1 < monthInfo.index)
);
});
};
const hasNewerChallenges = (
map: DailyChallengesMap,
monthInfo: MonthInfo
): boolean => {
return Array.from(map.keys()).some(dateStr => {
const [year, month] = dateStr.split('-').map(Number);
return (
year > monthInfo.year ||
(year === monthInfo.year && month - 1 > monthInfo.index)
);
});
};
const showPrevButton = monthInfo
? hasOlderChallenges(dailyChallengesMap, monthInfo)
: false;
const showNextButton = monthInfo
? hasNewerChallenges(dailyChallengesMap, monthInfo)
: false;
if (isLoading) return <Loader />;
if (error || !monthInfo) return <DailyCodingChallengeNotFound />;
return (
<>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Callout variant='info'>
{t('daily-coding-challenges.release-note')}
</Callout>
<Button
block={true}
href={`/learn/daily-coding-challenge?date=${todayUsCentral}`}
>
{t('buttons.go-to-today')}
</Button>
</Col>
<Spacer size='l' />
<div className='calendar-head'>
<Button
aria-label={t('aria.previous-month')}
disabled={!showPrevButton}
onClick={prevMonth}
>
&lt;
</Button>
<h2 className='text-center'>
{monthInfo.name} {monthInfo.year}
</h2>
<Button
aria-label={t('aria.next-month')}
disabled={!showNextButton}
onClick={nextMonth}
>
&gt;
</Button>
</div>
<Spacer size='m' />
<div className='calendar-weekday-labels'>
<div aria-label={t('weekdays.long.sunday')}>
{t('weekdays.short.sunday')}
</div>
<div aria-label={t('weekdays.long.monday')}>
{t('weekdays.short.monday')}
</div>
<div aria-label={t('weekdays.long.tuesday')}>
{t('weekdays.short.tuesday')}
</div>
<div aria-label={t('weekdays.long.wednesday')}>
{t('weekdays.short.wednesday')}
</div>
<div aria-label={t('weekdays.long.thursday')}>
{t('weekdays.short.thursday')}
</div>
<div aria-label={t('weekdays.long.friday')}>
{t('weekdays.short.friday')}
</div>
<div aria-label={t('weekdays.long.saturday')}>
{t('weekdays.short.saturday')}
</div>
</div>
<Spacer size='s' />
<div className='calendar-grid'>{monthInfo.days}</div>
<Spacer size='l' />
{!isSignedIn && (
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='m' />
<div className='completion-modal-login-btn'>
<Login block={true}>{t('buttons.logged-out-cta-btn')}</Login>
</div>
</Col>
)}
</>
);
}
DailyCodingChallengeCalendar.displayName = 'DailyCodingChallengeCalendar';
export default connect(mapStateToProps)(DailyCodingChallengeCalendar);

View File

@@ -0,0 +1,35 @@
import { parse, isValid, format } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
interface formatDateProps {
month: number;
day: number;
year: number;
}
// Format month, day, and year as "YYYY-MM-DD" with leading zeros
export function formatDate({ year, month, day }: formatDateProps) {
return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
}
// Returns the current US Central date in yyyy-MM-dd
export function getTodayUsCentral(dateObj: Date = new Date()) {
const zonedDate = toZonedTime(dateObj, 'America/Chicago');
return format(zonedDate, 'yyyy-MM-dd');
}
// Validate that dateString is in the format yyyy-MM-dd
// Leading zero's are accepted for single digit month/day
export function isValidDateParam(dateString: string) {
const parsedDate = parse(dateString, 'yyyy-MM-dd', new Date());
return isValid(parsedDate);
}
// Convert yyyy-MM-dd to display format (e.g: "January 1, 2025")
export function formatDisplayDate(dateString: string) {
const parsedDate = parse(dateString, 'yyyy-MM-dd', new Date());
if (!isValid(parsedDate)) {
return 'Invalid date';
}
return format(parsedDate, 'MMMM d, yyyy');
}

View File

@@ -0,0 +1,45 @@
.not-found-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 20px;
text-align: center;
}
.not-found-wrapper img {
width: 380px;
}
.quote-wrapper {
background-color: var(--tertiary-background);
padding-inline: 42px 20px;
padding-block: 20px;
border-width: 0;
position: relative;
max-width: 980px;
}
.quote-wrapper .quote {
font-style: italic;
font-size: 20px;
margin-bottom: 0.6em;
text-align: left;
}
.quote-wrapper .quote::before {
content: open-quote;
font-size: 25px;
font-weight: 700;
}
.quote-wrapper .author {
text-align: right;
margin: 0;
}
.button-wrapper {
width: 100%;
max-width: 635px;
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import Helmet from 'react-helmet';
import { useTranslation } from 'react-i18next';
import { Button, Container, Col, Row, Spacer } from '@freecodecamp/ui';
import { randomQuote } from '../../utils/get-words';
import { Link } from '../helpers';
import notFoundLogo from '../../assets/images/freeCodeCamp-404.svg';
import { getTodayUsCentral } from './helpers';
import './not-found.css';
function DailyCodingChallengeNotFound(): JSX.Element {
const { t } = useTranslation();
const quote = randomQuote();
return (
<Container>
<Row>
<Col
md={8}
mdOffset={2}
sm={10}
smOffset={1}
xs={12}
className='not-found-wrapper'
>
<Helmet title={t('404.page-not-found') + ' | freeCodeCamp.org'} />
<img alt={t('404.not-found')} src={notFoundLogo} />
<Spacer size='m' />
<h1 id='content-start'>{t('daily-coding-challenges.not-found')}</h1>
<Spacer size='m' />
<div>
<p>{t('404.heres-a-quote')}</p>
<Spacer size='m' />
<blockquote className='quote-wrapper'>
<p className='quote'>{quote.quote}</p>
<p className='author'>- {quote.author}</p>
</blockquote>
</div>
<Spacer size='l' />
<div className='button-wrapper'>
<Button
block={true}
href={`/learn/daily-coding-challenge?date=${getTodayUsCentral()}`}
>
{t(`buttons.go-to-today-long`)}
</Button>
<Spacer size='xs' />
<Button block={true} href='/learn/daily-coding-challenge/archive'>
{t(`buttons.go-to-archive-long`)}
</Button>
</div>
<Spacer size='l' />
<Link className='btn btn-cta' to='/learn'>
{t('buttons.view-curriculum')}
</Link>
</Col>
</Row>
</Container>
);
}
DailyCodingChallengeNotFound.displayName = 'DailyCodingChallengeNotFound';
export default DailyCodingChallengeNotFound;

View File

@@ -0,0 +1,20 @@
.daily-coding-challenge-info {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.daily-coding-challenge-date {
font-size: 1.2rem;
}
.release-note {
font-size: 0.8rem;
}
.daily-coding-challenge-button {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Spacer } from '@freecodecamp/ui';
import { ButtonLink } from '../helpers';
import DailyCodingChallengeIcon from '../../assets/icons/daily-coding-challenge';
import LinkButton from '../../assets/icons/link-button';
import CalendarIcon from '../../assets/icons/calendar';
import { getTodayUsCentral } from './helpers';
import './widget.css';
interface DailyCodingChallengeWidgetProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
forLanding: boolean;
}
function DailyCodingChallengeWidget({
forLanding
}: DailyCodingChallengeWidgetProps): JSX.Element {
const { t } = useTranslation();
return (
<>
<h2 className={forLanding ? 'big-heading' : ''}>
{t('daily-coding-challenges.map-title')}
</h2>
<div className='daily-coding-challenge-wrap'>
<ButtonLink
block
size='large'
className='map-superblock-link'
href={`/learn/daily-coding-challenge?date=${getTodayUsCentral()}`}
>
<div className='daily-coding-challenge-button'>
<DailyCodingChallengeIcon className='map-icon' />
{t(`buttons.start`)}
</div>
{forLanding && <LinkButton />}
</ButtonLink>
{!forLanding && (
<>
<Spacer size='xs' />
<ButtonLink
block={true}
size='large'
className='map-superblock-link'
href='/learn/daily-coding-challenge/archive'
>
<div className='daily-coding-challenge-button'>
<CalendarIcon className='map-icon' />
{t(`buttons.go-to-archive`)}
</div>
</ButtonLink>
</>
)}
</div>
</>
);
}
DailyCodingChallengeWidget.displayName = 'DailyCodingChallengeWidget';
export default DailyCodingChallengeWidget;

View File

@@ -59,6 +59,7 @@ import './global.css';
import './variables.css';
import './rtl-layout.css';
import { LocalStorageThemes } from '../../redux/types';
import DailyChallengeBreadCrumb from '../../templates/Challenges/components/daily-challenge-bread-crumb';
const mapStateToProps = createSelector(
isSignedInSelector,
@@ -112,6 +113,7 @@ interface DefaultLayoutProps extends StateProps, DispatchProps {
pathname: string;
showFooter?: boolean;
isChallenge?: boolean;
isDailyChallenge?: boolean;
usesMultifileEditor?: boolean;
block?: string;
examInProgress: boolean;
@@ -130,6 +132,7 @@ function DefaultLayout({
removeFlashMessage,
showFooter = true,
isChallenge = false,
isDailyChallenge = false,
usesMultifileEditor,
block,
superBlock,
@@ -287,7 +290,13 @@ function DefaultLayout({
/>
) : null}
<SignoutModal />
{isChallenge &&
{isDailyChallenge ? (
<div className='breadcrumbs-demo'>
<DailyChallengeBreadCrumb />
</div>
) : (
isChallenge &&
!isDailyChallenge &&
!examInProgress &&
(isRenderBreadcrumb ? (
<div className='breadcrumbs-demo'>
@@ -298,7 +307,8 @@ function DefaultLayout({
</div>
) : (
<Spacer size={isExSmallViewportHeight ? 'xxs' : 'xs'} />
))}
))
)}
{fetchState.complete && children}
</div>
{showFooter && <Footer />}

View File

@@ -0,0 +1,235 @@
import React, { useEffect, useState } from 'react';
import store from 'store';
import ShowClassic from '../../templates/Challenges/classic/show';
import { Loader } from '../../components/helpers';
import {
DailyCodingChallengeLanguages,
DailyCodingChallengeNode,
DailyCodingChallengePageContext
} from '../../redux/prop-types';
import DailyCodingChallengeNotFound from '../../components/daily-coding-challenge/not-found';
import FourOhFour from '../../components/FourOhFour';
import {
apiLocation,
showDailyCodingChallenges
} from '../../../config/env.json';
import { isValidDateParam } from '../../components/daily-coding-challenge/helpers';
import {
validateDailyCodingChallengeSchema,
type DailyCodingChallengeFromDb
} from '../../utils/daily-coding-challenge-validator';
interface DailyCodingChallengeLanguageData {
data: {
challengeNode: DailyCodingChallengeNode;
};
pageContext: DailyCodingChallengePageContext;
}
interface DailyCodingChallengeDataFormatted {
javascript: DailyCodingChallengeLanguageData;
python: DailyCodingChallengeLanguageData;
}
// These are not included in the data from the DB (Daily Challenge API) - so we add them in
function formatDescription(str: string) {
return `<section id="description">\n${str}\n</section>`;
}
function formatChallengeData({
date,
id,
challengeNumber,
title,
description,
javascript,
python
}: DailyCodingChallengeFromDb) {
const baseChallengeProps = {
date,
id,
challengeNumber,
title,
description: formatDescription(description),
superBlock: 'daily-coding-challenge',
block: 'daily-coding-challenge',
usesMultifileEditor: true,
// props to satisfy the show classic component
instructions: '',
demoType: null,
hooks: undefined,
hasEditableBoundaries: false,
forumTopicId: undefined,
notes: '',
videoUrl: undefined,
translationPending: false
};
const pageContext = {
challengeMeta: {
id,
superBlock: 'daily-coding-challenge',
block: 'daily-coding-challenge',
disableLoopProtectTests: true,
// props to satisfy the show classic component
isFirstStep: false,
nextChallegePath: undefined,
prevChallengePath: undefined,
disableLoopProtectPreview: false
},
// props to satisfy the show classic component
projectPreview: {
challengeData: undefined
}
};
const props = {
javascript: {
data: {
challengeNode: {
challenge: {
...baseChallengeProps,
helpCategory: 'JavaScript',
challengeType: 28,
fields: {
blockName: 'daily-coding-challenge',
tests: javascript.tests
},
challengeFiles: [
{
name: 'script',
ext: 'js',
contents: javascript.challengeFiles[0].contents,
head: '',
tail: '',
path: '',
history: ['script.js'],
fileKey: 'scriptjs'
}
]
}
}
},
pageContext
},
python: {
data: {
challengeNode: {
challenge: {
...baseChallengeProps,
helpCategory: 'Python',
challengeType: 29,
fields: {
blockName: 'daily-coding-challenge',
tests: python.tests
},
challengeFiles: [
{
fileKey: 'mainpy',
ext: 'py',
name: 'main',
contents: python.challengeFiles[0].contents,
head: '',
path: '',
tail: '',
editableRegionBoundaries: [],
history: ['main.py']
}
]
}
}
},
pageContext
}
};
return props;
}
function DailyCodingChallenge(): JSX.Element {
const initLanguage =
(store.get(
'dailyCodingChallengeLanguage'
) as DailyCodingChallengeLanguages) ?? 'javascript';
const [isLoading, setIsLoading] = useState(true);
const [challengeFound, setChallengeFound] = useState(false);
const [challengeProps, setChallengeProps] =
useState<null | DailyCodingChallengeDataFormatted>(null);
const [dailyCodingChallengeLanguage, setDailyCodingChallengeLanguage] =
useState<DailyCodingChallengeLanguages>(initLanguage);
const dateParam =
new URLSearchParams(window.location.search).get('date') || '';
const fetchChallenge = async (date: string) => {
try {
const response = await fetch(
`${apiLocation}/daily-coding-challenge/date/${date}`
);
const challengeData = await response.json();
if (challengeData) {
const validDailyCodingChallenge = validateDailyCodingChallengeSchema(
challengeData as DailyCodingChallengeFromDb
);
if ('error' in validDailyCodingChallenge) {
throw new Error(
`Challenge data validation failed: ${validDailyCodingChallenge.error?.message || 'Unknown validation error'}`
);
}
const formattedChallengeData = formatChallengeData(
challengeData as DailyCodingChallengeFromDb
) as DailyCodingChallengeDataFormatted;
setChallengeProps(formattedChallengeData);
setChallengeFound(true);
} else {
setChallengeFound(false);
}
} catch (error) {
setChallengeFound(false);
console.error('Error fetching data:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// If dateParam is invalid, stop loading/fetching and show the not found page
if (!isValidDateParam(dateParam)) {
setIsLoading(false);
setChallengeFound(false);
return;
}
void fetchChallenge(dateParam);
}, [dateParam]);
if (!showDailyCodingChallenges) {
return <FourOhFour />;
}
if (isLoading) return <Loader />;
if (!challengeFound || !challengeProps)
return <DailyCodingChallengeNotFound />;
return (
<ShowClassic
isDailyCodingChallenge={true}
dailyCodingChallengeLanguage={dailyCodingChallengeLanguage}
setDailyCodingChallengeLanguage={setDailyCodingChallengeLanguage}
{...challengeProps[dailyCodingChallengeLanguage]}
/>
);
}
DailyCodingChallenge.displayName = 'DailyCodingChallenge';
export default DailyCodingChallenge;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Container, Col, Row, Spacer } from '@freecodecamp/ui';
import Map from '../../../components/Map';
import DailyCodingChallengeCalendar from '../../../components/daily-coding-challenge/calendar';
import DailyCodingChallengeIcon from '../../../assets/icons/daily-coding-challenge';
import FourOhFour from '../../../components/FourOhFour';
import { showDailyCodingChallenges } from '../../../../config/env.json';
function Archive(): JSX.Element {
const { t } = useTranslation();
if (!showDailyCodingChallenges) {
return <FourOhFour />;
}
return (
<Container>
<Row>
<Col md={12} sm={12} xs={12}>
<Spacer size='l' />
<h1 className='text-center big-heading'>
{t('daily-coding-challenges.title')}
</h1>
<Spacer size='m' />
<DailyCodingChallengeIcon className='cert-header-icon' />
<Spacer size='l' />
<DailyCodingChallengeCalendar />
</Col>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='l' />
<Map />
<Spacer size='l' />
</Col>
</Row>
</Container>
);
}
Archive.displayName = 'Archive';
export default Archive;

View File

@@ -6,6 +6,7 @@ import {
actionTypes as challengeTypes,
CURRENT_CHALLENGE_KEY
} from '../templates/Challenges/redux/action-types';
import { getIsDailyCodingChallenge } from '../../../shared/config/challenge-types';
import { createAcceptTermsSaga } from './accept-terms-saga';
import { actionTypes, ns as MainApp } from './action-types';
import { createAppMountSaga } from './app-mount-saga';
@@ -319,43 +320,64 @@ export const reducer = handleActions(
[actionTypes.submitComplete]: (state, { payload }) => {
const {
examResults = null,
completedDailyCodingChallenges = [],
submittedChallenge,
savedChallenges
} = payload;
let submittedchallenges = [
{ ...submittedChallenge, completedDate: Date.now() }
];
return examResults && !examResults.passed
? {
...state,
user: {
...state.user,
sessionUser: {
...state.user.sessionUser,
examResults
}
// if daily coding challenge, only update completedDailyCodingChallenges
// Uses the whole completedDailyCodingChallenges array from the API response
// Todo: update with submittedChallenge instead
if (getIsDailyCodingChallenge(submittedChallenge.challengeType)) {
return {
...state,
user: {
...state.user,
sessionUser: {
...state.user.sessionUser,
completedDailyCodingChallenges
}
}
: {
...state,
user: {
...state.user,
sessionUser: {
...state.user.sessionUser,
completedChallenges: uniqBy(
[
...submittedchallenges,
...state.user.sessionUser.completedChallenges
],
'id'
),
savedChallenges:
savedChallenges ?? savedChallengesSelector(state[MainApp]),
examResults
}
};
}
// if exam not passed, don't update completedChallenges - only update the results
if (examResults && !examResults.passed) {
return {
...state,
user: {
...state.user,
sessionUser: {
...state.user.sessionUser,
examResults
}
};
}
};
}
return {
...state,
user: {
...state.user,
sessionUser: {
...state.user.sessionUser,
completedChallenges: uniqBy(
[
...submittedchallenges,
...state.user.sessionUser.completedChallenges
],
'id'
),
savedChallenges:
savedChallenges ?? savedChallengesSelector(state[MainApp]),
examResults
}
}
};
},
[actionTypes.setMsUsername]: (state, { payload }) => {
return {

View File

@@ -246,6 +246,72 @@ export interface Hooks {
afterEach?: string;
}
export type PageContext = {
challengeMeta: ChallengeMeta;
projectPreview: {
challengeData: ChallengeData;
};
};
export type DailyCodingChallengeNode = {
challenge: {
date: string;
id: string;
challengeNumber: number;
title: string;
description: string;
superBlock: 'daily-coding-challenge';
block: 'daily-coding-challenge';
usesMultifileEditor: true;
helpCategory: 'JavaScript' | 'Python';
challengeType: 28 | 29;
fields: {
blockName: 'daily-coding-challenge';
tests: Test[];
};
challengeFiles: ChallengeFiles;
// props to satisfy the show classic component
instructions: string;
demoType: null;
hooks?: { beforeAll: string };
hasEditableBoundaries?: false;
forumTopicId?: number;
notes: string;
videoUrl?: string;
translationPending: false;
};
};
export type DailyCodingChallengePageContext = {
challengeMeta: {
block: 'daily-coding-challenge';
id: string;
superBlock: 'daily-coding-challenge';
disableLoopProtectTests: boolean;
// props to satisfy the show classic component
isFirstStep: boolean;
nextChallengePath?: string;
prevChallengePath?: string;
disableLoopProtectPreview: boolean;
};
// props to satisfy the show classic component
projectPreview: {
challengeData?: null;
};
};
export type DailyCodingChallengeLanguages = 'javascript' | 'python';
export interface CompletedDailyCodingChallenge {
id: string;
completedDate: number;
completedLanguages: DailyCodingChallengeLanguages[];
}
type Quiz = {
questions: QuizQuestion[];
};
@@ -413,9 +479,8 @@ export interface ChallengeData extends CompletedChallenge {
export type ChallengeMeta = {
block: string;
id: string;
introPath: string;
isFirstStep: boolean;
superBlock: SuperBlocks;
superBlock: SuperBlocks | 'daily-coding-challenge';
title?: string;
challengeType?: number;
blockType?: BlockTypes;

View File

@@ -9,6 +9,8 @@ export const savedChallengesSelector = state =>
userSelector(state)?.savedChallenges || [];
export const completedChallengesSelector = state =>
userSelector(state)?.completedChallenges || [];
export const completedDailyCodingChallengesSelector = state =>
userSelector(state)?.completedDailyCodingChallenges || [];
export const userIdSelector = state => userSelector(state)?.id;
export const partiallyCompletedChallengesSelector = state =>
userSelector(state)?.partiallyCompletedChallenges || [];
@@ -31,8 +33,11 @@ export const donatableSectionRecentlyCompletedSelector = state => {
if (donatableSectionRecentlyCompletedState) {
const { block, module, superBlock } =
donatableSectionRecentlyCompletedState;
if (module) return { section: 'module', title: module, superBlock };
else if (block) return { section: 'block', title: block, superBlock };
if (superBlock !== 'daily-coding-challenge') {
if (module) return { section: 'module', title: module, superBlock };
else if (block) return { section: 'block', title: block, superBlock };
}
}
return null;
@@ -120,6 +125,11 @@ export const completedChallengesIdsSelector = createSelector(
completedChallenges => completedChallenges.map(node => node.id)
);
export const completedDailyCodingChallengesIdsSelector = createSelector(
completedDailyCodingChallengesSelector,
completedChallenges => completedChallenges.map(node => node.id)
);
export const completionStateSelector = createSelector(
[allChallengesInfoSelector, completedChallengesIdsSelector],
(allChallengesInfo, completedChallengesIds) => {

View File

@@ -2,12 +2,19 @@ import { faWindowRestore } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { useTranslation } from 'react-i18next';
import store from 'store';
import { DailyCodingChallengeLanguages } from '../../../redux/prop-types';
import EditorTabs from './editor-tabs';
interface ActionRowProps {
dailyCodingChallengeLanguage: DailyCodingChallengeLanguages;
hasNotes: boolean;
hasPreview: boolean;
areInstructionsDisplayable: boolean;
isDailyCodingChallenge: boolean;
setDailyCodingChallengeLanguage: (
language: DailyCodingChallengeLanguages
) => void;
showConsole: boolean;
showNotes: boolean;
showInstructions: boolean;
@@ -25,7 +32,10 @@ const ActionRow = ({
showPreviewPortal,
showConsole,
showInstructions,
areInstructionsDisplayable
areInstructionsDisplayable,
isDailyCodingChallenge,
dailyCodingChallengeLanguage,
setDailyCodingChallengeLanguage
}: ActionRowProps): JSX.Element => {
const { t } = useTranslation();
@@ -51,20 +61,50 @@ const ActionRow = ({
return previewBtnsSrText;
}
const handleLanguageChange = (language: DailyCodingChallengeLanguages) => {
store.set('dailyCodingChallengeLanguage', language);
setDailyCodingChallengeLanguage(language);
};
return (
<div className='action-row' data-playwright-test-label='action-row'>
<div className='tabs-row' data-playwright-test-label='tabs-row'>
{areInstructionsDisplayable && (
<button
data-playwright-test-label='instructions-button'
aria-expanded={!!showInstructions}
onClick={() => togglePane('showInstructions')}
>
{t('learn.editor-tabs.instructions')}
</button>
)}
<EditorTabs data-playwright-test-label='editor-tabs' />
<div className='panel-display-tabs'>
{/* left */}
<div className='tabs-row-left'>
{areInstructionsDisplayable && (
<button
data-playwright-test-label='instructions-button'
aria-expanded={!!showInstructions}
onClick={() => togglePane('showInstructions')}
>
{t('learn.editor-tabs.instructions')}
</button>
)}
<EditorTabs data-playwright-test-label='editor-tabs' />
</div>
{/* middle - only used with daily coding challenges for now */}
<div className='tabs-row-middle'>
{isDailyCodingChallenge && (
<>
<button
aria-expanded={dailyCodingChallengeLanguage === 'javascript'}
disabled={dailyCodingChallengeLanguage === 'javascript'}
onClick={() => handleLanguageChange('javascript')}
>
JavaScript
</button>
<button
aria-expanded={dailyCodingChallengeLanguage === 'python'}
disabled={dailyCodingChallengeLanguage === 'python'}
onClick={() => handleLanguageChange('python')}
>
Python
</button>
</>
)}
</div>
{/* right */}
<div className='tabs-row-right panel-display-tabs'>
<button
aria-expanded={!!showConsole}
onClick={() => togglePane('showConsole')}

View File

@@ -62,6 +62,25 @@
border-style: solid;
}
.tabs-row-left {
width: 30%;
display: flex;
gap: 10px;
}
.tabs-row-middle {
width: 30%;
display: flex;
justify-content: center;
gap: 3px;
}
.tabs-row-right {
width: 30%;
display: flex;
justify-content: flex-end;
}
.monaco-editor-tabs button + button {
margin-inline-start: 3px;
}

View File

@@ -5,7 +5,11 @@ import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import store from 'store';
import { challengeTypes } from '../../../../../shared/config/challenge-types';
import { ChallengeFiles, ResizeProps } from '../../../redux/prop-types';
import {
ChallengeFiles,
DailyCodingChallengeLanguages,
ResizeProps
} from '../../../redux/prop-types';
import {
removePortalWindow,
setShowPreviewPortal,
@@ -27,11 +31,16 @@ interface DesktopLayoutProps {
challengeFiles: ChallengeFiles;
challengeType: number;
editor: ReactElement | null;
hasEditableBoundaries: boolean;
hasEditableBoundaries?: boolean;
hasPreview: boolean;
instructions: ReactElement;
isAdvancing: boolean;
isFirstStep: boolean;
isDailyCodingChallenge: boolean;
dailyCodingChallengeLanguage: DailyCodingChallengeLanguages;
setDailyCodingChallengeLanguage: (
language: DailyCodingChallengeLanguages
) => void;
isFirstStep?: boolean;
layoutState: {
codePane: Pane;
editorPane: Pane;
@@ -40,7 +49,7 @@ interface DesktopLayoutProps {
previewPane: Pane;
testsPane: Pane;
};
notes: string;
notes?: string;
onPreviewResize: () => void;
preview: ReactElement;
resizeProps: ResizeProps;
@@ -94,7 +103,10 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
setShowPreviewPortal,
portalWindow,
startWithConsoleShown,
showIndependentLowerJaw
showIndependentLowerJaw,
isDailyCodingChallenge,
dailyCodingChallengeLanguage,
setDailyCodingChallengeLanguage
} = props;
const initialShowState = (key: string, defaultValue: boolean): boolean => {
@@ -265,6 +277,9 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => {
hasPreview={hasPreview}
hasNotes={!!notes}
areInstructionsDisplayable={areInstructionsDisplayable}
isDailyCodingChallenge={isDailyCodingChallenge}
dailyCodingChallengeLanguage={dailyCodingChallengeLanguage}
setDailyCodingChallengeLanguage={setDailyCodingChallengeLanguage}
showConsole={showConsole}
showNotes={showNotes}
showInstructions={showInstructions}

View File

@@ -111,7 +111,7 @@ export interface EditorProps {
contents: string;
editableRegionBoundaries?: number[];
}) => void;
usesMultifileEditor: boolean;
usesMultifileEditor?: boolean;
isChallengeCompleted: boolean;
showIndependentLowerJaw?: boolean;
}
@@ -349,7 +349,7 @@ const Editor = (props: EditorProps): JSX.Element => {
};
const editorWillMount = (monaco: typeof monacoEditor) => {
const { usesMultifileEditor } = props;
const { usesMultifileEditor = false } = props;
monacoRef.current = monaco;
defineMonacoThemes(monaco, { usesMultifileEditor });
@@ -421,7 +421,7 @@ const Editor = (props: EditorProps): JSX.Element => {
addContentChangeListener();
resetAttempts();
showEditableRegion(editor);
if (isMathJaxAllowed(props.superBlock)) {
if (props.superBlock && isMathJaxAllowed(props.superBlock)) {
initializeMathJax();
}
}
@@ -800,7 +800,7 @@ const Editor = (props: EditorProps): JSX.Element => {
const desc = document.createElement('div');
const descContainer = document.createElement('div');
descContainer.classList.add('description-container');
if (isMathJaxAllowed(props.superBlock)) {
if (props.superBlock && isMathJaxAllowed(props.superBlock)) {
descContainer.classList.add('mathjax-support');
}
domNode.classList.add('editor-upper-jaw');

View File

@@ -16,11 +16,14 @@ import LearnLayout from '../../../components/layouts/learn';
import { MAX_MOBILE_WIDTH } from '../../../../config/misc';
import type {
ChallengeData,
ChallengeFiles,
ChallengeMeta,
ChallengeNode,
Hooks,
DailyCodingChallengeLanguages,
DailyCodingChallengeNode,
DailyCodingChallengePageContext,
PageContext,
ResizeProps,
SavedChallenge,
SavedChallengeFiles,
@@ -78,7 +81,7 @@ import '../components/test-frame.css';
const mapStateToProps = (state: unknown) => ({
challengeFiles: challengeFilesSelector(state) as ChallengeFiles,
output: consoleOutputSelector(state) as string,
isChallengeCompleted: isChallengeCompletedSelector(state) as boolean,
isChallengeCompleted: isChallengeCompletedSelector(state),
savedChallenges: savedChallengesSelector(state) as SavedChallenge[]
});
@@ -106,7 +109,8 @@ interface ShowClassicProps extends Pick<PreviewProps, 'previewMounted'> {
cancelTests: () => void;
challengeMounted: (arg0: string) => void;
createFiles: (arg0: ChallengeFiles | SavedChallengeFiles) => void;
data: { challengeNode: ChallengeNode };
dailyCodingChallengeLanguage: DailyCodingChallengeLanguages;
data: { challengeNode: ChallengeNode | DailyCodingChallengeNode };
executeChallenge: (options?: { showCompletionModal: boolean }) => void;
challengeFiles: ChallengeFiles;
initConsole: (arg0: string) => void;
@@ -114,15 +118,14 @@ interface ShowClassicProps extends Pick<PreviewProps, 'previewMounted'> {
initHooks: (hooks?: Hooks) => void;
initVisibleEditors: () => void;
isChallengeCompleted: boolean;
isDailyCodingChallenge?: boolean;
output: string;
pageContext: {
challengeMeta: ChallengeMeta;
projectPreview: {
challengeData: ChallengeData;
};
};
pageContext: PageContext | DailyCodingChallengePageContext;
updateChallengeMeta: (arg0: ChallengeMeta) => void;
openModal: (modal: string) => void;
setDailyCodingChallengeLanguage: (
language: DailyCodingChallengeLanguages
) => void;
setEditorFocusability: (canFocus: boolean) => void;
setIsAdvancing: (arg: boolean) => void;
savedChallenges: SavedChallenge[];
@@ -202,7 +205,7 @@ function ShowClassic({
hooks,
fields: { tests, blockName },
challengeType,
hasEditableBoundaries,
hasEditableBoundaries = false,
superBlock,
helpCategory,
forumTopicId,
@@ -225,6 +228,9 @@ function ShowClassic({
initTests,
initHooks,
initVisibleEditors,
dailyCodingChallengeLanguage,
isDailyCodingChallenge = false,
setDailyCodingChallengeLanguage,
updateChallengeMeta,
openModal,
setIsAdvancing,
@@ -359,7 +365,7 @@ function ShowClassic({
window.removeEventListener('resize', setHtmlHeight);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [dailyCodingChallengeLanguage]);
const initializeComponent = (title: string): void => {
initConsole('');
@@ -512,6 +518,9 @@ function ShowClassic({
toolPanel: <ToolPanel guideUrl={guideUrl} videoUrl={videoUrl} />,
hasDemo: demoType === 'onClick'
})}
isDailyCodingChallenge={isDailyCodingChallenge}
dailyCodingChallengeLanguage={dailyCodingChallengeLanguage}
setDailyCodingChallengeLanguage={setDailyCodingChallengeLanguage}
isFirstStep={isFirstStep}
layoutState={layout}
notes={notes}

View File

@@ -6,7 +6,7 @@ import './challenge-description.css';
type Props = {
description?: string;
instructions?: string;
superBlock: string;
superBlock?: string;
};
const ChallengeDescription = ({
@@ -15,7 +15,7 @@ const ChallengeDescription = ({
superBlock
}: Props) => {
useEffect(() => {
if (isMathJaxAllowed(superBlock)) {
if (superBlock && isMathJaxAllowed(superBlock)) {
initializeMathJax();
}
}, [superBlock]);

View File

@@ -8,7 +8,7 @@ import './challenge-title.css';
interface ChallengeTitleProps {
children: string;
isCompleted: boolean;
translationPending: boolean;
translationPending?: boolean;
}
function ChallengeTitle({

View File

@@ -0,0 +1,45 @@
import i18next from 'i18next';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from '../../../components/helpers/index';
import './challenge-title.css';
import {
isValidDateParam,
formatDisplayDate
} from '../../../components/daily-coding-challenge/helpers';
function DailyChallengeBreadCrumb(): JSX.Element {
const dateParam =
new URLSearchParams(window.location.search).get('date') || '';
let displayDate = '';
if (isValidDateParam(dateParam)) {
displayDate = formatDisplayDate(dateParam);
}
const { t } = useTranslation();
return (
<nav
className='challenge-title-breadcrumbs'
aria-label={t('aria.breadcrumb-nav')}
>
<ol data-playwright-test-label='breadcrumb-desktop'>
<li className='breadcrumb-left'>
<Link to={`/learn`}>
<span>{i18next.t(`intro:daily-coding-challenge.title`)}</span>
</Link>
</li>
<li className='breadcrumb-right'>
<Link to={`/learn/daily-coding-challenge/archive`}>
{displayDate}
</Link>
</li>
</ol>
</nav>
);
}
DailyChallengeBreadCrumb.displayName = 'DailyChallengeBreadCrumb';
export default DailyChallengeBreadCrumb;

View File

@@ -22,7 +22,7 @@ interface Props {
closeModal: (arg: string) => void;
isOpen: boolean;
projectPreviewMounted: (payload: ProjectPreviewMountedPayload) => void;
challengeData: ChallengeData | null;
challengeData?: ChallengeData | null;
setEditorFocusability: (focusability: boolean) => void;
previewTitle: string;
closeText: string;
@@ -41,7 +41,7 @@ function ProjectPreviewModal({
closeModal,
isOpen,
projectPreviewMounted,
challengeData,
challengeData = null,
setEditorFocusability,
previewTitle,
closeText

View File

@@ -48,7 +48,7 @@ interface ToolPanelProps {
openVideoModal: () => void;
openResetModal: () => void;
guideUrl: string;
videoUrl: string;
videoUrl?: string;
}
function ToolPanel({

View File

@@ -333,7 +333,7 @@ function ShowExam(props: ShowExamProps) {
stopExam
} = props;
stopExam();
void navigate(blockHashSlug);
void navigate(blockHashSlug || '/learn');
};
let missingPrerequisites: PrerequisiteChallenge[] = [];

View File

@@ -51,7 +51,7 @@ import '../video.css';
// Redux Setup
const mapStateToProps = (state: unknown) => ({
isChallengeCompleted: isChallengeCompletedSelector(state) as boolean
isChallengeCompleted: isChallengeCompletedSelector(state)
});
const mapDispatchToProps = {

View File

@@ -248,7 +248,7 @@ const ShowQuiz = ({
const handleExitQuizModalBtnClick = () => {
setExitConfirmed(true);
void navigate(exitPathname);
void navigate(exitPathname || '/learn');
closeExitQuizModal();
};

View File

@@ -8,6 +8,10 @@ import { createFlashMessage } from '../../../components/Flash/redux';
import { FlashMessages } from '../../../components/Flash/redux/flash-messages';
import { savedChallengesSelector } from '../../../redux/selectors';
import { actionTypes as appTypes } from '../../../redux/action-types';
import {
getIsDailyCodingChallenge,
getDailyCodingChallengeLanguage
} from '../../../../../shared/config/challenge-types';
import { actionTypes } from './action-types';
import { noStoredCodeFound, updateFile } from './actions';
import { challengeFilesSelector, challengeMetaSelector } from './selectors';
@@ -71,8 +75,14 @@ function clearCodeEpic(action$, state$) {
return action$.pipe(
ofType(appTypes.submitComplete, actionTypes.resetChallenge),
tap(() => {
const { id } = challengeMetaSelector(state$.value);
store.remove(id);
const { challengeType, id } = challengeMetaSelector(state$.value);
const isDailyCodingChallenge = getIsDailyCodingChallenge(challengeType);
const storageId = isDailyCodingChallenge
? id + getDailyCodingChallengeLanguage(challengeType)
: id;
store.remove(storageId);
}),
ignoreElements()
);
@@ -84,13 +94,21 @@ function saveCodeEpic(action$, state$) {
// do not save challenge if code is locked
map(action => {
const state = state$.value;
const { id } = challengeMetaSelector(state);
const { id, challengeType } = challengeMetaSelector(state);
const challengeFiles = challengeFilesSelector(state);
try {
store.set(id, challengeFiles);
const isDailyCodingChallenge = getIsDailyCodingChallenge(challengeType);
const storageId = isDailyCodingChallenge
? id + getDailyCodingChallengeLanguage(challengeType)
: id;
store.set(storageId, challengeFiles);
const stored = store.get(storageId);
const fileKey = challengeFiles[0].fileKey;
if (
store.get(id).find(challengeFile => challengeFile.fileKey === fileKey)
stored.find(challengeFile => challengeFile.fileKey === fileKey)
.contents !==
challengeFiles.find(challengeFile => challengeFile.fileKey).contents
) {
@@ -130,7 +148,15 @@ function loadCodeEpic(action$, state$) {
const fileKeys = challengeFiles.map(x => x.fileKey);
const invalidForLegacy = fileKeys.length > 1;
const { title: legacyKey } = challenge;
const codeFound = getCode(id);
const isDailyCodingChallenge = getIsDailyCodingChallenge(
challenge.challengeType
);
const storageId = isDailyCodingChallenge
? id + getDailyCodingChallengeLanguage(challenge.challengeType)
: id;
const codeFound = getCode(storageId);
// first check if the store (which is synchronized with the db) has saved
// code
@@ -180,7 +206,7 @@ function loadCodeEpic(action$, state$) {
// Repair the store, by replacing old style code with the repaired
// file
store.set(id, [indexjsCode]);
} else {
} else if (!getIsDailyCodingChallenge(challenge.challengeType)) {
// The stored code is neither old code nor new, so we do not know
// how to handle it. The safest option is to delete it.
store.remove(id);

View File

@@ -18,6 +18,8 @@ import {
import {
canSaveToDB,
challengeTypes,
getIsDailyCodingChallenge,
getDailyCodingChallengeLanguage,
submitTypes
} from '../../../../../shared/config/challenge-types';
import { actionTypes as submitActionTypes } from '../../../redux/action-types';
@@ -59,7 +61,13 @@ function postChallenge(update) {
const saveChallenge = postUpdate$(update).pipe(
retry(3),
switchMap(({ data }) => {
const { type, savedChallenges, message, examResults } = data;
const {
type,
completedDailyCodingChallenges,
savedChallenges,
message,
examResults
} = data;
const payloadWithClientProperties = {
...omit(update.payload, ['files'])
};
@@ -75,6 +83,7 @@ function postChallenge(update) {
let actions = [
submitComplete({
submittedChallenge: payloadWithClientProperties,
completedDailyCodingChallenges,
savedChallenges: mapFilesToChallengeFiles(savedChallenges),
examResults
}),
@@ -103,7 +112,6 @@ function postChallenge(update) {
}
function submitModern(type, state) {
const challengeType = state.challenge.challengeMeta.challengeType;
const tests = challengeTestsSelector(state);
if (tests.length === 0 || tests.every(test => test.pass && !test.err)) {
if (type === actionTypes.checkChallenge) {
@@ -111,26 +119,44 @@ function submitModern(type, state) {
}
if (type === actionTypes.submitChallenge) {
const { id, block } = challengeMetaSelector(state);
const challengeFiles = challengeFilesSelector(state);
const { id, block, challengeType } = challengeMetaSelector(state);
let body;
if (
block === 'javascript-algorithms-and-data-structures-projects' ||
canSaveToDB(challengeType)
) {
body = standardizeRequestBody({ id, challengeType, challengeFiles });
} else {
body = {
let update;
if (getIsDailyCodingChallenge(challengeType)) {
const language = getDailyCodingChallengeLanguage(challengeType);
const body = {
id,
challengeType
challengeType,
language
};
update = {
endpoint: '/daily-coding-challenge-completed',
payload: body
};
} else {
const challengeFiles = challengeFilesSelector(state);
let body;
if (
block === 'javascript-algorithms-and-data-structures-projects' ||
canSaveToDB(challengeType)
) {
body = standardizeRequestBody({ id, challengeType, challengeFiles });
} else {
body = {
id,
challengeType
};
}
update = {
endpoint: '/modern-challenge-completed',
payload: body
};
}
const update = {
endpoint: '/modern-challenge-completed',
payload: body
};
return postChallenge(update);
}
}
@@ -254,9 +280,16 @@ export default function completionEpic(action$, state$) {
submitter = submitters[submitTypes[challengeType]];
}
let pathToNavigateTo = isLastChallengeInBlock
? blockHashSlug
: nextChallengePath;
let pathToNavigateTo = nextChallengePath;
if (isLastChallengeInBlock) {
pathToNavigateTo = blockHashSlug;
}
// TODO: Navigate to the next daily challenge if it exists - archive if not.
if (getIsDailyCodingChallenge(challengeType)) {
pathToNavigateTo = '/learn/daily-coding-challenge/archive';
}
const canAllowDonationRequest = (state, action) => {
if (action.type !== submitActionTypes.submitComplete) return null;

View File

@@ -5,7 +5,8 @@ import {
allChallengesInfoSelector,
isSignedInSelector,
completionStateSelector,
completedChallengesIdsSelector
completedChallengesIdsSelector,
completedDailyCodingChallengesIdsSelector
} from '../../../redux/selectors';
import {
getCurrentBlockIds,
@@ -26,8 +27,12 @@ export const consoleOutputSelector = state => {
: out;
};
export const isChallengeCompletedSelector = createSelector(
[completedChallengesIdsSelector, challengeMetaSelector],
(ids, meta) => ids.includes(meta.id)
[
completedChallengesIdsSelector,
completedDailyCodingChallengesIdsSelector,
challengeMetaSelector
],
(ids1, ids2, meta) => [...ids1, ...ids2].includes(meta.id)
);
export const isCodeLockedSelector = state => state[ns].isCodeLocked;
export const isCompletionModalOpenSelector = state =>

View File

@@ -44,7 +44,7 @@ function IntroductionPage({
/>
</FullWidthRow>
<FullWidthRow>
<ButtonLink block size='large' href={firstLessonPath}>
<ButtonLink block size='large' href={firstLessonPath || '/learn'}>
{t('buttons.first-lesson')}
</ButtonLink>
<Spacer size='xs' />

View File

@@ -0,0 +1,58 @@
import Joi from 'joi';
interface DailyCodingChallengeLanguageFromDb {
tests: {
text: string;
testString: string;
}[];
challengeFiles: {
fileKey: string;
contents: string;
}[];
}
export interface DailyCodingChallengeFromDb {
id: string;
challengeNumber: number;
title: string;
date: string;
description: string;
javascript: DailyCodingChallengeLanguageFromDb;
python: DailyCodingChallengeLanguageFromDb;
}
const challengeLanguageDataSchema = Joi.object({
tests: Joi.array()
.items(
Joi.object({
text: Joi.string().required(),
testString: Joi.string().required()
})
)
.required(),
challengeFiles: Joi.array()
.items(
Joi.object({
fileKey: Joi.string().required(),
contents: Joi.string().required()
})
)
.required(),
disableLoopProtectTests: Joi.boolean().optional()
});
const challengeDataFromDbSchema = Joi.object({
id: Joi.string().required(),
challengeNumber: Joi.number().integer().min(1).required(),
title: Joi.string().required(),
date: Joi.string().required(),
description: Joi.string().required(),
javascript: challengeLanguageDataSchema.required(),
python: challengeLanguageDataSchema.required()
});
export const validateDailyCodingChallengeSchema = (
challenge: DailyCodingChallengeFromDb
) => {
return challengeDataFromDbSchema.validate(challenge);
};

View File

@@ -64,7 +64,8 @@ if (FREECODECAMP_NODE_ENV !== 'development') {
'curriculumLocale',
'deploymentEnv',
'environment',
'showUpcomingChanges'
'showUpcomingChanges',
'showDailyCodingChallenges'
];
const searchKeys = ['algoliaAppId', 'algoliaAPIKey'];
const donationKeys = ['stripePublicKey', 'paypalClientId', 'patreonClientId'];

View File

@@ -32,6 +32,7 @@ const {
PATREON_CLIENT_ID: patreonClientId,
DEPLOYMENT_ENV: deploymentEnv,
SHOW_UPCOMING_CHANGES: showUpcomingChanges,
SHOW_DAILY_CODING_CHALLENGES: showDailyCodingChallenges,
GROWTHBOOK_URI: growthbookUri
} = process.env;
@@ -71,6 +72,7 @@ export default Object.assign(locations, {
? null
: patreonClientId,
showUpcomingChanges: showUpcomingChanges === 'true',
showDailyCodingChallenges: showDailyCodingChallenges === 'true',
growthbookUri:
!growthbookUri || growthbookUri === 'api_URI_from_Growthbook_dashboard'
? null

View File

@@ -20,7 +20,10 @@ export default function layoutSelector({
location: { pathname }
} = props;
const isChallenge = !!props.pageContext?.challengeMeta;
const isDailyChallenge =
props.location.pathname === '/learn/daily-coding-challenge';
const isChallenge = !!props.pageContext?.challengeMeta || isDailyChallenge;
if (element.type === FourOhFourPage) {
return (
@@ -38,6 +41,7 @@ export default function layoutSelector({
pathname={pathname}
showFooter={false}
isChallenge={true}
isDailyChallenge={isDailyChallenge}
usesMultifileEditor={
props.data?.challengeNode?.challenge?.usesMultifileEditor
}

View File

@@ -54,6 +54,7 @@ CURRICULUM_LOCALE=english
# Show or hide WIP in progress challenges
SHOW_UPCOMING_CHANGES=false
SHOW_DAILY_CODING_CHALLENGES=false
# ---------------------
# New API