mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 18:18:27 -05:00
feat(client): add daily challenges (#60867)
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
21
client/src/assets/icons/calendar.tsx
Normal file
21
client/src/assets/icons/calendar.tsx
Normal 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;
|
||||
21
client/src/assets/icons/daily-coding-challenge.tsx
Normal file
21
client/src/assets/icons/daily-coding-challenge.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
102
client/src/components/daily-coding-challenge/calendar.css
Normal file
102
client/src/components/daily-coding-challenge/calendar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
314
client/src/components/daily-coding-challenge/calendar.tsx
Normal file
314
client/src/components/daily-coding-challenge/calendar.tsx
Normal 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}
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
|
||||
<h2 className='text-center'>
|
||||
{monthInfo.name} {monthInfo.year}
|
||||
</h2>
|
||||
<Button
|
||||
aria-label={t('aria.next-month')}
|
||||
disabled={!showNextButton}
|
||||
onClick={nextMonth}
|
||||
>
|
||||
>
|
||||
</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);
|
||||
35
client/src/components/daily-coding-challenge/helpers.ts
Normal file
35
client/src/components/daily-coding-challenge/helpers.ts
Normal 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');
|
||||
}
|
||||
45
client/src/components/daily-coding-challenge/not-found.css
Normal file
45
client/src/components/daily-coding-challenge/not-found.css
Normal 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;
|
||||
}
|
||||
65
client/src/components/daily-coding-challenge/not-found.tsx
Normal file
65
client/src/components/daily-coding-challenge/not-found.tsx
Normal 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;
|
||||
20
client/src/components/daily-coding-challenge/widget.css
Normal file
20
client/src/components/daily-coding-challenge/widget.css
Normal 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;
|
||||
}
|
||||
66
client/src/components/daily-coding-challenge/widget.tsx
Normal file
66
client/src/components/daily-coding-challenge/widget.tsx
Normal 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;
|
||||
@@ -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 />}
|
||||
|
||||
235
client/src/pages/learn/daily-coding-challenge.tsx
Normal file
235
client/src/pages/learn/daily-coding-challenge.tsx
Normal 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;
|
||||
42
client/src/pages/learn/daily-coding-challenge/archive.tsx
Normal file
42
client/src/pages/learn/daily-coding-challenge/archive.tsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import './challenge-title.css';
|
||||
interface ChallengeTitleProps {
|
||||
children: string;
|
||||
isCompleted: boolean;
|
||||
translationPending: boolean;
|
||||
translationPending?: boolean;
|
||||
}
|
||||
|
||||
function ChallengeTitle({
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ interface ToolPanelProps {
|
||||
openVideoModal: () => void;
|
||||
openResetModal: () => void;
|
||||
guideUrl: string;
|
||||
videoUrl: string;
|
||||
videoUrl?: string;
|
||||
}
|
||||
|
||||
function ToolPanel({
|
||||
|
||||
@@ -333,7 +333,7 @@ function ShowExam(props: ShowExamProps) {
|
||||
stopExam
|
||||
} = props;
|
||||
stopExam();
|
||||
void navigate(blockHashSlug);
|
||||
void navigate(blockHashSlug || '/learn');
|
||||
};
|
||||
|
||||
let missingPrerequisites: PrerequisiteChallenge[] = [];
|
||||
|
||||
@@ -51,7 +51,7 @@ import '../video.css';
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = (state: unknown) => ({
|
||||
isChallengeCompleted: isChallengeCompletedSelector(state) as boolean
|
||||
isChallengeCompleted: isChallengeCompletedSelector(state)
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
|
||||
@@ -248,7 +248,7 @@ const ShowQuiz = ({
|
||||
|
||||
const handleExitQuizModalBtnClick = () => {
|
||||
setExitConfirmed(true);
|
||||
void navigate(exitPathname);
|
||||
void navigate(exitPathname || '/learn');
|
||||
closeExitQuizModal();
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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' />
|
||||
|
||||
58
client/src/utils/daily-coding-challenge-validator.ts
Normal file
58
client/src/utils/daily-coding-challenge-validator.ts
Normal 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);
|
||||
};
|
||||
@@ -64,7 +64,8 @@ if (FREECODECAMP_NODE_ENV !== 'development') {
|
||||
'curriculumLocale',
|
||||
'deploymentEnv',
|
||||
'environment',
|
||||
'showUpcomingChanges'
|
||||
'showUpcomingChanges',
|
||||
'showDailyCodingChallenges'
|
||||
];
|
||||
const searchKeys = ['algoliaAppId', 'algoliaAPIKey'];
|
||||
const donationKeys = ['stripePublicKey', 'paypalClientId', 'patreonClientId'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user