From aed5adc6b91f68ec85320eb7391ba07eda02a515 Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 14:58:01 -0400 Subject: [PATCH 1/7] add completed challenge feedback --- src/cli.ts | 4 +-- .../parse-command-arguments-and-options.ts | 4 +-- src/tasks/prompt-for-missing-user-state.ts | 14 ++++---- src/types.ts | 35 +++++++++---------- src/utils/stateManager.ts | 12 +++---- src/utils/tree.ts | 26 ++++++++------ 6 files changed, 48 insertions(+), 47 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 905681d..ed94be8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import { promptForMissingUserState } from "./tasks/prompt-for-missing-user-state"; import { renderIntroMessage } from "./tasks/render-intro-message"; -import type { Args, UserState } from "./types"; +import type { Args, IUser } from "./types"; import { startVisualization } from "./utils/tree"; import { loadUserState, saveChallenges } from "./utils/stateManager"; import { fetchChallenges } from "./modules/api"; @@ -23,7 +23,7 @@ export async function cli(args: Args) { } } -async function init(userState: UserState) { +async function init(userState: IUser) { // Use local user state or prompt to retrieve user from server await promptForMissingUserState(userState); diff --git a/src/tasks/parse-command-arguments-and-options.ts b/src/tasks/parse-command-arguments-and-options.ts index 4967c07..5e1bab0 100644 --- a/src/tasks/parse-command-arguments-and-options.ts +++ b/src/tasks/parse-command-arguments-and-options.ts @@ -1,5 +1,5 @@ import arg from "arg"; -import { UserState } from "../types"; +import { IUser } from "../types"; import fs from "fs"; import inquirer from "inquirer"; import { isValidAddress } from "../utils/helpers"; @@ -75,7 +75,7 @@ export async function parseCommandArgumentsAndOptions( return argumentObject as CommandOptions; } -export async function promptForMissingCommandArgs(commands: CommandOptions, userState: UserState): Promise { +export async function promptForMissingCommandArgs(commands: CommandOptions, userState: IUser): Promise { const cliAnswers = Object.fromEntries( Object.entries(commands).filter(([key, value]) => value !== null) ); diff --git a/src/tasks/prompt-for-missing-user-state.ts b/src/tasks/prompt-for-missing-user-state.ts index c50fbc2..bc04702 100644 --- a/src/tasks/prompt-for-missing-user-state.ts +++ b/src/tasks/prompt-for-missing-user-state.ts @@ -1,19 +1,17 @@ import { getUser, upsertUser } from "../modules/api"; -import { - UserState -} from "../types"; +import { IUser } from "../types"; import inquirer from "inquirer"; import { saveUserState } from "../utils/stateManager"; import { isValidAddressOrENS, getDevice, checkValidPathOrCreate, isValidAddress } from "../utils/helpers"; // default values for unspecified args -const defaultOptions: Partial = { +const defaultOptions: Partial = { installLocation: process.cwd() + '/challenges', }; export async function promptForMissingUserState( - userState: UserState -): Promise { + userState: IUser +): Promise { const userDevice = getDevice(); let identifier = userState.address; @@ -58,9 +56,9 @@ export async function promptForMissingUserState( user = await upsertUser(user); } - const { address, ens, installLocations } = user; + const { address, ens, installLocations, challenges, creationTimestamp } = user; const thisDeviceLocation = installLocations.find((loc: {location: string, device: string}) => loc.device === userDevice); - const newState = { address, ens, installLocation: thisDeviceLocation.location }; + const newState = { address, ens, installLocation: thisDeviceLocation.location, challenges, creationTimestamp }; if (JSON.stringify(userState) !== JSON.stringify(newState)) { // Save the new state locally await saveUserState(newState); diff --git a/src/types.ts b/src/types.ts index be32a31..0a42e5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,12 +82,21 @@ export type TemplateDescriptor = { source: string; }; -export type UserState = { - address?: `0x${string}`; - installLocation: string; - lastCompletedChallenge?: string; - lastTreeNode?: string; +export interface IUserChallenge { + challengeName: string + status: string; + lastFeedback: string; + timestamp: Date; + contractAddress: string; + network: string; + gasReport?: IGasReport[]; +} + +export interface IGasReport { + functionName: string; + gasUsed: number; } + export interface IChallenge { type: string; level: number; @@ -102,22 +111,10 @@ export interface IChallenge { description: string; } -export interface IUserChallenge { - status: string; - lastFeedback: string; - timestamp: number; - contractAddress: string; - network: string; - gasReport?: { - [key: string]: number; - }; -} - export interface IUser { address: string; ens: string; + installLocation: string; creationTimestamp: number; - challenges: { - [key: string]: IUserChallenge; - }; + challenges: IUserChallenge[]; } \ No newline at end of file diff --git a/src/utils/stateManager.ts b/src/utils/stateManager.ts index 0804fe8..606849b 100644 --- a/src/utils/stateManager.ts +++ b/src/utils/stateManager.ts @@ -1,11 +1,11 @@ import fs from 'fs'; import { promisify } from 'util'; import path from 'path'; -import { IChallenge, UserState } from '../types'; +import { IChallenge, IUser } from '../types'; const writeFile = promisify(fs.writeFile); -export async function saveUserState(state: UserState) { +export async function saveUserState(state: IUser) { const configPath = path.join(process.cwd(), "storage"); if (!fs.existsSync(configPath)) { fs.mkdirSync(configPath); @@ -14,14 +14,14 @@ export async function saveUserState(state: UserState) { await writeFile(filePath, JSON.stringify(state, null, 2)); } -export function loadUserState(): UserState { +export function loadUserState(): IUser { try { const configPath = path.join(process.cwd(), "storage", `user.json`); const data = fs.readFileSync(configPath, 'utf8'); - return JSON.parse(data) as UserState; + return JSON.parse(data) as IUser; } catch (error: any) { if (error.code === 'ENOENT') { - return {} as UserState; // Return empty object if file doesn't exist + return {} as IUser; // Return empty object if file doesn't exist } throw error; } @@ -48,4 +48,4 @@ export function loadChallenges(): IChallenge[] { } throw error; } -} +} \ No newline at end of file diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 3e1093a..84e6c37 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -2,7 +2,7 @@ import inquirer from "inquirer"; import chalk from "chalk"; import { loadChallenges, loadUserState } from "./stateManager"; import { testChallenge, submitChallenge, setupChallenge } from "../actions"; -import { IChallenge } from "../types"; +import { IChallenge, IUserChallenge } from "../types"; import fs from "fs"; import { pressEnterToContinue } from "./helpers"; @@ -34,11 +34,11 @@ function getNodeLabel(node: TreeNode, depth: string = ""): string { if (isHeader) { return `${depth} ${chalk.blue(label)}`; } else if (isChallenge) { - return `${depth} ${label} โ™Ÿ๏ธ - LVL ${level}`; + return `${depth} ${label} ${completed ? "๐Ÿ‘‘" : "โ™Ÿ๏ธ"}`; } else if (isQuiz) { - return `${depth} ${label} ๐Ÿ“– - LVL ${level}`; + return `${depth} ${label} ๐Ÿ“–`; } else if (isCapstoneProject) { - return`${depth} ${label} ๐Ÿ† - LVL ${level}`; + return`${depth} ${label} ๐Ÿ†`; } else { return `${depth} ${label}`; } @@ -56,7 +56,7 @@ async function selectNode(node: TreeNode): Promise { // submit project, check if project passes tests then send proof of completion to the BG server, if it passes, mark the challenge as completed if (node.type === "challenge") { const backAction: Action = { - label: " โฎข", + label: "โคด๏ธ", action: async () => { console.clear(); await startVisualization(header); @@ -64,7 +64,6 @@ async function selectNode(node: TreeNode): Promise { } const actions = [backAction].concat((node.actions as Action[]).map(action => action)); const choices = actions.map(action => action.label); - console.log("This is a challenge"); const actionPrompt = { type: "list", name: "selectedAction", @@ -137,7 +136,7 @@ export async function startVisualization(currentNode?: TreeNode): Promise let defaultChoice = 0; // Add a back option if not at the root if (parent) { - choices.unshift(" โฎข"); + choices.unshift(" โคด๏ธ"); actions.unshift(parent); defaultChoice = 1; } @@ -200,15 +199,22 @@ export function buildTree(): TreeNode { const { installLocation } = loadUserState(); const tree: TreeNode[] = []; const challenges = loadChallenges(); + const userState = loadUserState(); + const userChallenges = userState.challenges; const tags = challenges.reduce((acc: string[], challenge: any) => { return Array.from(new Set(acc.concat(challenge.tags))); }, []); + for (let tag of tags) { const filteredChallenges = challenges.filter((challenge: IChallenge) => challenge.tags.includes(tag) && challenge.enabled); + let completedCount = 0; const transformedChallenges = filteredChallenges.map((challenge: IChallenge) => { const { label, name, level, type, repo, childrenNames} = challenge; const parentName = challenges.find((c: any) => c.childrenNames?.includes(name))?.name; - + const completed = userChallenges.find((c: IUserChallenge) => c.challengeName === name)?.status === "success"; + if (completed) { + completedCount++; + } // Build selection actions const actions: Action[] = []; if (type === "challenge") { @@ -273,13 +279,13 @@ export function buildTree(): TreeNode { }); } - return { label, name, level, type, actions, childrenNames, parentName }; + return { label, name, level, type, actions, completed, childrenNames, parentName }; }); const NestingChallenges = NestingMagic(transformedChallenges); tree.push({ type: "header", - label: `${tag}`, + label: `${tag} ${chalk.green(`(${completedCount}/${filteredChallenges.length})`)}`, name: `${tag.toLowerCase()}`, children: NestingChallenges, recursive: true From dd727ef18e3f96aae233e263dad79b03f20c531a Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 15:10:58 -0400 Subject: [PATCH 2/7] fetch user challenges after submit --- src/utils/tree.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 84e6c37..4300519 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -1,10 +1,11 @@ import inquirer from "inquirer"; import chalk from "chalk"; -import { loadChallenges, loadUserState } from "./stateManager"; +import { loadChallenges, loadUserState, saveUserState } from "./stateManager"; import { testChallenge, submitChallenge, setupChallenge } from "../actions"; import { IChallenge, IUserChallenge } from "../types"; import fs from "fs"; import { pressEnterToContinue } from "./helpers"; +import { getUser } from "../modules/api"; type Action = { label: string; @@ -196,11 +197,10 @@ function NestingMagic(challenges: any[], parentName: string | undefined = undefi } export function buildTree(): TreeNode { - const { installLocation } = loadUserState(); + const userState = loadUserState(); + const { address, installLocation, challenges: userChallenges } = userState; const tree: TreeNode[] = []; const challenges = loadChallenges(); - const userState = loadUserState(); - const userChallenges = userState.challenges; const tags = challenges.reduce((acc: string[], challenge: any) => { return Array.from(new Set(acc.concat(challenge.tags))); }, []); @@ -253,6 +253,11 @@ export function buildTree(): TreeNode { console.clear(); // Submit the challenge await submitChallenge(name); + // Fetch users challenge state from the server + const newUserState = await getUser(address); + userState.challenges = newUserState.challenges; + // Save the new user state locally + await saveUserState(userState); // Rebuild the tree globalTree = buildTree(); // Wait for enter key From cf53f836fa901c5fc389c5aa2cbf519d47c16e3e Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 15:13:57 -0400 Subject: [PATCH 3/7] change question based on level --- src/utils/tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 4300519..66f6893 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -145,7 +145,7 @@ export async function startVisualization(currentNode?: TreeNode): Promise type: "list", loop: false, name: "selectedNodeIndex", - message: "Select an option", + message: parent ? "Select a challenge" : "Select a category", choices, default: defaultChoice }; From 8042d88955b507a6bf8d6f74f3f27c88e35690fe Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 15:25:05 -0400 Subject: [PATCH 4/7] update emojis --- src/utils/tree.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 66f6893..b097a79 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -35,11 +35,11 @@ function getNodeLabel(node: TreeNode, depth: string = ""): string { if (isHeader) { return `${depth} ${chalk.blue(label)}`; } else if (isChallenge) { - return `${depth} ${label} ${completed ? "๐Ÿ‘‘" : "โ™Ÿ๏ธ"}`; + return `${depth} ${label} ${completed ? "๐Ÿ†" : "๐Ÿ—๏ธ"}`; } else if (isQuiz) { - return `${depth} ${label} ๐Ÿ“–`; + return `${depth} ${label} ๐Ÿ“œ`; } else if (isCapstoneProject) { - return`${depth} ${label} ๐Ÿ†`; + return`${depth} ${label} ๐Ÿ’ป`; } else { return `${depth} ${label}`; } From a6efce4cf4510cdaeb76df5fc638df6de003a90b Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 15:31:30 -0400 Subject: [PATCH 5/7] adjust header depth in tree --- src/utils/tree.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/tree.ts b/src/utils/tree.ts index b097a79..87a8933 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -123,7 +123,8 @@ export async function startVisualization(currentNode?: TreeNode): Promise depth = depth.replace(/โ””โ”€/g, " "); } // Add spaces so that the labels are spaced out - depth += Array(Math.floor(node.label.length/2)).fill(" ").join(""); + const depthDivisor = node.type === "header" ? 5 : 2; + depth += Array(Math.floor(node.label.length / depthDivisor)).fill(" ").join(""); node.children.forEach((child, i, siblings) => getChoicesAndActionsRecursive(child, i === siblings.length - 1, depth)); }; From c97a03995170cfd7352ce281ffdecd073bbad70a Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 15:48:10 -0400 Subject: [PATCH 6/7] show all challenges --- src/utils/tree.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/utils/tree.ts b/src/utils/tree.ts index 87a8933..2cb67cb 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -19,6 +19,7 @@ type TreeNode = { type: "header" | "challenge" | "quiz" | "capstone-project"; completed?: boolean; level?: number; + unlocked?: boolean; actions?: Action[]; repo?: string; message?: string; @@ -26,14 +27,17 @@ type TreeNode = { } function getNodeLabel(node: TreeNode, depth: string = ""): string { - const { label, level, type, completed } = node; + const { label, level, type, completed, unlocked } = node; const isHeader = type === "header"; const isChallenge = type === "challenge"; const isQuiz = type === "quiz"; const isCapstoneProject = type === "capstone-project"; - + + if (isHeader) { return `${depth} ${chalk.blue(label)}`; + } else if (!unlocked) { + return `${depth} ${label} ๐Ÿ”’`; } else if (isChallenge) { return `${depth} ${label} ${completed ? "๐Ÿ†" : "๐Ÿ—๏ธ"}`; } else if (isQuiz) { @@ -55,7 +59,12 @@ async function selectNode(node: TreeNode): Promise { // download repository - Use create-eth to download repository using extensions // - Show instructions for completing the challenge including a simple command to test their code // submit project, check if project passes tests then send proof of completion to the BG server, if it passes, mark the challenge as completed - if (node.type === "challenge") { + if (node.type !== "header" && !node.unlocked) { + console.log("This challenge is locked because it doesn't exist yet... ๐Ÿ˜…"); + await pressEnterToContinue(); + console.clear(); + await startVisualization(header); + } else if (node.type === "challenge") { const backAction: Action = { label: "โคด๏ธ", action: async () => { @@ -85,7 +94,6 @@ async function selectNode(node: TreeNode): Promise { // IF: type === personal-challenge // Show description of challenge - } @@ -207,10 +215,10 @@ export function buildTree(): TreeNode { }, []); for (let tag of tags) { - const filteredChallenges = challenges.filter((challenge: IChallenge) => challenge.tags.includes(tag) && challenge.enabled); + const filteredChallenges = challenges.filter((challenge: IChallenge) => challenge.tags.includes(tag)); let completedCount = 0; const transformedChallenges = filteredChallenges.map((challenge: IChallenge) => { - const { label, name, level, type, repo, childrenNames} = challenge; + const { label, name, level, type, repo, childrenNames, enabled: unlocked } = challenge; const parentName = challenges.find((c: any) => c.childrenNames?.includes(name))?.name; const completed = userChallenges.find((c: IUserChallenge) => c.challengeName === name)?.status === "success"; if (completed) { @@ -285,7 +293,7 @@ export function buildTree(): TreeNode { }); } - return { label, name, level, type, actions, completed, childrenNames, parentName }; + return { label, name, level, type, actions, completed, childrenNames, parentName, unlocked }; }); const NestingChallenges = NestingMagic(transformedChallenges); From 6c317e1660b435a0b79b221421f1e6df59fb90b8 Mon Sep 17 00:00:00 2001 From: "escottalexander@gmail.com" Date: Thu, 3 Oct 2024 15:49:25 -0400 Subject: [PATCH 7/7] changeset --- .changeset/shy-bats-brake.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shy-bats-brake.md diff --git a/.changeset/shy-bats-brake.md b/.changeset/shy-bats-brake.md new file mode 100644 index 0000000..ae223bd --- /dev/null +++ b/.changeset/shy-bats-brake.md @@ -0,0 +1,5 @@ +--- +"eth-tech-tree": patch +--- + +ui improvements