[
  {
    "es": "ES2025",
    "id": "set-methods",
    "title": "Set Methods",
    "description": "Sets finally have built-in operations for union, intersection, difference, and more. No more manual loops.\n[Proposal](https://github.com/tc39/proposal-set-methods)",
    "starterCode": "// Problem: checking overlap between two groups\n// Before: manual iteration\nconst frontend = new Set([\"React\", \"Vue\", \"Svelte\"]);\nconst backend = new Set([\"Express\", \"Hono\", \"Svelte\"]);\n\n// Old way: find common items manually\nconst commonOld = [...frontend].filter(x => backend.has(x));\nconsole.log(\"old intersection:\", commonOld);\n\n// ES2025: built-in Set methods\nconsole.log(\"intersection:\", [...frontend.intersection(backend)]);\nconsole.log(\"union:\", [...frontend.union(backend)]);\nconsole.log(\"difference:\", [...frontend.difference(backend)]);\nconsole.log(\"isDisjointFrom:\", frontend.isDisjointFrom(new Set([\"Go\"])));",
    "exercise": "You have a Set of owned books and a Set of wishlist books. Find which wishlist books you already own using intersection.",
    "hint": "wishlist.intersection(owned)",
    "solution": "const owned = new Set([\"Dune\", \"Neuromancer\", \"Snow Crash\"]);\nconst wishlist = new Set([\"Snow Crash\", \"Foundation\", \"Dune\"]);\nconsole.log(\"already own:\", [...wishlist.intersection(owned)]);\nconsole.log(\"still need:\", [...wishlist.difference(owned)]);"
  },
  {
    "es": "ES2025",
    "id": "iterator-helpers",
    "title": "Iterator Helpers",
    "description": "Chain lazy .map(), .filter(), .take() on any iterator. No intermediate arrays created.\n[Proposal](https://github.com/tc39/proposal-iterator-helpers)",
    "starterCode": "// Problem: process a large list but only need a few results\nconst prices = [12, 45, 8, 99, 23, 67, 5, 150];\n\n// Before: builds TWO full arrays, then slices\nconst oldWay = prices\n  .filter(p => p > 20)\n  .map(p => \"$\" + p)\n  .slice(0, 3);\nconsole.log(\"old:\", oldWay);\n\n// ES2025: lazy - stops as soon as 3 found\nconst newWay = prices.values()\n  .filter(p => p > 20)\n  .map(p => \"$\" + p)\n  .take(3)\n  .toArray();\nconsole.log(\"new:\", newWay);\n\n// Works on Maps too\nconst stock = new Map([[\"AAPL\", 190], [\"GOOG\", 140], [\"TSLA\", 250]]);\nconst expensive = stock.values().filter(v => v > 150).toArray();\nconsole.log(\"expensive:\", expensive);",
    "exercise": "From an array of scores [95, 40, 72, 88, 55, 91, 30], use iterator helpers to get the first 3 passing scores (>= 60).",
    "hint": "scores.values().filter(...).take(3).toArray()",
    "solution": "const scores = [95, 40, 72, 88, 55, 91, 30];\nconst passing = scores.values()\n  .filter(s => s >= 60)\n  .take(3)\n  .toArray();\nconsole.log(\"first 3 passing:\", passing);"
  },
  {
    "es": "ES2025",
    "id": "promise-try",
    "title": "Promise.try",
    "description": "Safely wraps any function (sync or async) into a Promise. Sync throws become rejections instead of uncaught errors.\n[Proposal](https://github.com/tc39/proposal-promise-try)",
    "starterCode": "// Problem: a function might throw synchronously or return async\nfunction parseConfig(text) {\n  return JSON.parse(text); // throws on bad input!\n}\n\n// Before: awkward wrapping to catch sync throws\nconst fn = parseConfig;\nnew Promise(resolve => resolve(fn('{\"port\": 3000}')))\n  .then(c => console.log(\"old ok:\", c.port))\n  .catch(e => console.log(\"old err:\", e.message));\n\n// ES2025: clean, catches both sync throws and async rejections\nPromise.try(fn, '{\"port\": 8080}')\n  .then(c => console.log(\"new ok:\", c.port))\n  .catch(e => console.log(\"new err:\", e.message));\n\n// Sync throw becomes a rejection (no uncaught error)\nPromise.try(fn, \"not json\")\n  .catch(e => console.log(\"caught sync throw:\", e.message));",
    "exercise": "Write a function that reads a key from an object (might throw if object is null). Use Promise.try to safely call it.",
    "hint": "Promise.try(yourFn, arg).catch(...)",
    "solution": "function getKey(obj) {\n  return obj.name; // throws if obj is null\n}\nPromise.try(getKey, null)\n  .then(v => console.log(\"got:\", v))\n  .catch(e => console.log(\"safe catch:\", e.message));"
  },
  {
    "es": "ES2025",
    "id": "regexp-modifiers",
    "title": "RegExp Modifiers & Duplicate Named Captures",
    "description": "Apply regex flags to just part of a pattern, and reuse capture group names in alternations.\n[Modifiers](https://github.com/tc39/proposal-regexp-modifiers) | [Duplicate Names](https://github.com/tc39/proposal-duplicate-named-capturing-groups)",
    "starterCode": "// Problem: you want the same group name for different date formats\n// Before: couldn't reuse \"day\" and \"month\" in alternations\n\n// ES2025: same capture name in different branches\nconst dateRe = /(?<day>\\d{2})\\/(?<month>\\d{2})|(?<month>\\w+) (?<day>\\d{2})/;\n\n// Test with numeric format\nconst m1 = \"25/12\".match(dateRe);\nconsole.log(\"numeric:\", m1?.groups);\n\n// Test with text format\nconst m2 = \"December 25\".match(dateRe);\nconsole.log(\"text:\", m2?.groups);",
    "exercise": "Create a regex that matches both \"2024-05-04\" and \"04/05/2024\" and captures the year in a group named \"year\" in both branches.",
    "hint": "Use alternation (|) with the same group name in each branch",
    "solution": "const re = /(?<year>\\d{4})-\\d{2}-\\d{2}|\\d{2}\\/\\d{2}\\/(?<year>\\d{4})/;\nconsole.log(\"2024-05-04\".match(re)?.groups);\nconsole.log(\"04/05/2024\".match(re)?.groups);"
  },
  {
    "es": "ES2024",
    "id": "groupby",
    "title": "Object.groupBy & Map.groupBy",
    "description": "Group array items into an object or Map by a key function. Replaces manual reduce-based grouping.\n[Proposal](https://github.com/tc39/proposal-array-grouping)",
    "starterCode": "// Problem: group items by a property\nconst items = [\n  { name: \"shirt\", type: \"clothing\", price: 25 },\n  { name: \"pants\", type: \"clothing\", price: 45 },\n  { name: \"apple\", type: \"food\", price: 2 },\n  { name: \"bread\", type: \"food\", price: 4 },\n];\n\n// Before: manual reduce\nconst oldWay = items.reduce((groups, item) => {\n  const key = item.type;\n  groups[key] = groups[key] || [];\n  groups[key].push(item.name);\n  return groups;\n}, {});\nconsole.log(\"old:\", oldWay);\n\n// ES2024: one line\nconst grouped = Object.groupBy(items, item => item.type);\nconsole.log(\"new:\", Object.keys(grouped));\nconsole.log(\"food:\", grouped.food.map(i => i.name));",
    "exercise": "Group an array of numbers [1,2,3,4,5,6] into \"even\" and \"odd\" using Object.groupBy.",
    "hint": "Object.groupBy(nums, n => n % 2 === 0 ? \"even\" : \"odd\")",
    "solution": "const nums = [1, 2, 3, 4, 5, 6];\nconst grouped = Object.groupBy(nums, n => n % 2 === 0 ? \"even\" : \"odd\");\nconsole.log(\"even:\", grouped.even);\nconsole.log(\"odd:\", grouped.odd);"
  },
  {
    "es": "ES2024",
    "id": "promise-withresolvers",
    "title": "Promise.withResolvers",
    "description": "Create a Promise and get its resolve/reject functions separately. No more nesting inside the executor.\n[Proposal](https://github.com/tc39/proposal-promise-with-resolvers)",
    "starterCode": "// Problem: you need the resolve/reject outside the Promise constructor\n// Before: save references from inside the callback\nlet savedResolve;\nconst oldPromise = new Promise(resolve => {\n  savedResolve = resolve;\n});\nsavedResolve(\"from outside\");\noldPromise.then(v => console.log(\"old:\", v));\n\n// ES2024: clean destructuring\nconst { promise, resolve } = Promise.withResolvers();\nresolve(\"from outside, cleanly\");\npromise.then(v => console.log(\"new:\", v));",
    "exercise": "Use Promise.withResolvers to create a promise that resolves after a setTimeout of 100ms.",
    "hint": "const { promise, resolve } = Promise.withResolvers(); setTimeout(() => resolve(...), 100)",
    "solution": "const { promise, resolve } = Promise.withResolvers();\nsetTimeout(() => resolve(\"done!\"), 100);\npromise.then(v => console.log(v));"
  },
  {
    "es": "ES2024",
    "id": "well-formed-strings",
    "title": "isWellFormed & toWellFormed",
    "description": "Check if a string has valid Unicode (no lone surrogates) and fix broken strings.\n[Proposal](https://github.com/tc39/proposal-is-usv-string)",
    "starterCode": "// Problem: strings can contain broken Unicode (lone surrogates)\nconst good = \"Hello world\";\nconst bad = \"ab\\uD800cd\"; // lone surrogate - invalid Unicode\n\n// Before: no easy way to detect or fix\nconsole.log(\"good looks fine:\", good);\nconsole.log(\"bad looks fine:\", bad); // looks ok but is broken\n\n// ES2024: detect broken strings\nconsole.log(\"good valid?\", good.isWellFormed());\nconsole.log(\"bad valid?\", bad.isWellFormed());\n\n// Fix broken strings by replacing lone surrogates\nconsole.log(\"fixed:\", bad.toWellFormed());",
    "exercise": "Check if a user-submitted string is well-formed. If not, fix it with toWellFormed() before logging.",
    "hint": "if (!str.isWellFormed()) str = str.toWellFormed()",
    "solution": "const input = \"test\\uD800data\";\nconst safe = input.isWellFormed() ? input : input.toWellFormed();\nconsole.log(\"safe:\", safe);\nconsole.log(\"valid now?\", safe.isWellFormed());"
  },
  {
    "es": "ES2024",
    "id": "regexp-v-flag",
    "title": "RegExp /v Flag (Unicode Sets)",
    "description": "The v flag enables set operations (intersection, subtraction) inside character classes.\n[Proposal](https://github.com/nicolo-ribaudo/proposal-regexp-v-flag)",
    "starterCode": "// Problem: match characters that belong to multiple Unicode categories\n// Before: very hard to express \"Greek letters only\" or \"emoji but not digits\"\n\n// ES2024: v flag allows set operations in character classes\n// Match ASCII digits AND letters (intersection-like via subtraction)\nconst lettersOnly = /[\\p{Letter}--\\p{ASCII}]/v;\nconsole.log(\"matches e:\", lettersOnly.test(\"e\"));\nconsole.log(\"matches 5:\", lettersOnly.test(\"5\"));\n\n// String properties with v flag\nconst emojiRe = /^\\p{RGI_Emoji}$/v;\nconsole.log(\"flag emoji:\", emojiRe.test(\"🇺🇸\"));",
    "exercise": "Create a regex with the v flag that matches any letter but NOT ASCII letters (i.e., only non-Latin scripts).",
    "hint": "Use [\\p{Letter}--\\p{ASCII}] with the v flag",
    "solution": "const nonAsciiLetters = /[\\p{Letter}--\\p{ASCII}]/v;\nconsole.log(\"matches:\", nonAsciiLetters.test(\"a\")); // false\nconsole.log(\"matches:\", nonAsciiLetters.test(\"5\")); // false"
  },
  {
    "es": "ES2023",
    "id": "find-from-end",
    "title": "findLast & findLastIndex",
    "description": "Search arrays from the end. Faster and clearer than reversing or using lastIndexOf.\n[Proposal](https://github.com/tc39/proposal-array-find-from-last)",
    "starterCode": "// Problem: find the last item matching a condition\nconst transactions = [\n  { id: 1, type: \"credit\", amount: 100 },\n  { id: 2, type: \"debit\", amount: 50 },\n  { id: 3, type: \"credit\", amount: 200 },\n  { id: 4, type: \"debit\", amount: 75 },\n];\n\n// Before: reverse then find (creates a copy!)\nconst oldWay = [...transactions].reverse().find(t => t.type === \"credit\");\nconsole.log(\"old:\", oldWay);\n\n// ES2023: search from the end directly\nconst lastCredit = transactions.findLast(t => t.type === \"credit\");\nconsole.log(\"new:\", lastCredit);\n\nconst lastCreditIdx = transactions.findLastIndex(t => t.type === \"credit\");\nconsole.log(\"index:\", lastCreditIdx);",
    "exercise": "Find the last even number in [3, 8, 12, 7, 4, 9] and its index.",
    "hint": "arr.findLast(n => n % 2 === 0)",
    "solution": "const nums = [3, 8, 12, 7, 4, 9];\nconsole.log(\"last even:\", nums.findLast(n => n % 2 === 0));\nconsole.log(\"at index:\", nums.findLastIndex(n => n % 2 === 0));"
  },
  {
    "es": "ES2023",
    "id": "immutable-array",
    "title": "Immutable Array Methods",
    "description": "Sort, reverse, and splice arrays without changing the original. Returns a new copy.\n[Proposal](https://github.com/tc39/proposal-change-array-by-copy)",
    "starterCode": "// Problem: sort() and reverse() mutate the original array\nconst scores = [90, 30, 70, 50];\n\n// Before: mutation! original is destroyed\nconst copy = [...scores];\ncopy.sort((a, b) => a - b);\nconsole.log(\"sorted copy:\", copy);\nconsole.log(\"original ok?\", scores); // still [90,30,70,50]\n\n// ES2023: non-mutating versions\nconst sorted = scores.toSorted((a, b) => a - b);\nconsole.log(\"toSorted:\", sorted);\nconsole.log(\"original:\", scores); // unchanged!\n\nconst reversed = scores.toReversed();\nconsole.log(\"toReversed:\", reversed);\n\n// Replace at index without mutation\nconst replaced = scores.with(0, 100);\nconsole.log(\"with(0, 100):\", replaced);\nconsole.log(\"original:\", scores); // still unchanged",
    "exercise": "Given const colors = [\"red\", \"green\", \"blue\"], replace index 1 with \"yellow\" without mutating the original.",
    "hint": "colors.with(1, \"yellow\")",
    "solution": "const colors = [\"red\", \"green\", \"blue\"];\nconst updated = colors.with(1, \"yellow\");\nconsole.log(\"new:\", updated);\nconsole.log(\"original:\", colors);"
  },
  {
    "es": "ES2023",
    "id": "symbols-weakmap-keys",
    "title": "Symbols as WeakMap Keys",
    "description": "Symbols can now be used as WeakMap keys, enabling private metadata without object references.\n[Proposal](https://github.com/tc39/proposal-symbols-as-weakmap-keys)",
    "starterCode": "// Problem: WeakMap keys had to be objects\n// Before: create a throwaway object just to use as a key\nconst metadata = new WeakMap();\nconst key = {}; // dummy object\nmetadata.set(key, { created: Date.now() });\nconsole.log(\"old:\", metadata.get(key));\n\n// ES2023: Symbols work as WeakMap keys\nconst SECRET = Symbol(\"secret\");\nconst wm = new WeakMap();\nwm.set(SECRET, \"hidden value\");\nconsole.log(\"new:\", wm.get(SECRET));\n\n// Useful for cross-realm metadata\nconst TAG = Symbol(\"component-tag\");\nwm.set(TAG, { version: \"1.0\" });\nconsole.log(\"tag:\", wm.get(TAG));",
    "exercise": "Create a Symbol key and use it to store a secret message in a WeakMap. Retrieve and log it.",
    "hint": "const key = Symbol(\"my-key\"); wm.set(key, ...)",
    "solution": "const wm = new WeakMap();\nconst key = Symbol(\"password\");\nwm.set(key, \"hunter2\");\nconsole.log(\"secret:\", wm.get(key));"
  },
  {
    "es": "ES2023",
    "id": "hashbang",
    "title": "Hashbang Grammar",
    "description": "JavaScript files can start with #!/usr/bin/env node so they run directly from the command line.\n[Proposal](https://github.com/tc39/proposal-hashbang)",
    "starterCode": "// Problem: making a JS file executable on Unix/Mac\n// Before: the #! line caused a syntax error in JS engines\n\n// ES2023: engines now ignore the hashbang line\n// In a real file, the first line would be:\n// #!/usr/bin/env node\n\n// This means you can do:\n//   chmod +x myscript.js\n//   ./myscript.js\n\n// The script runs directly without typing \"node\" first\nconst args = [\"--verbose\", \"--output=build\"];\nconsole.log(\"Script args:\", args);\nconsole.log(\"This file could run as: ./script.js\", args.join(\" \"));",
    "exercise": "Write what the first line of a directly-executable Node.js script would look like (as a comment, since we are in a browser).",
    "hint": "It starts with #! and points to the node binary",
    "solution": "// First line of an executable script:\n// #!/usr/bin/env node\nconsole.log(\"I could run as ./script.js\");\nconsole.log(\"After: chmod +x script.js\");"
  },
  {
    "es": "ES2022",
    "id": "class-fields",
    "title": "Class Fields & Private",
    "description": "Declare fields directly in a class body and make them truly private with #. No more constructor boilerplate.\n[Proposal](https://github.com/tc39/proposal-class-fields)",
    "starterCode": "// Problem: class setup required everything in the constructor\n// Before:\nclass OldCounter {\n  constructor() {\n    this.count = 0;     // public\n    this._max = 100;    // \"private\" by convention (anyone can access!)\n  }\n}\nconst old = new OldCounter();\nconsole.log(\"old _max:\", old._max); // accessible!\n\n// ES2022: fields declared in body, # makes truly private\nclass Counter {\n  count = 0;       // public field\n  #max = 100;      // truly private\n\n  increment() {\n    if (this.count < this.#max) this.count++;\n  }\n}\nconst c = new Counter();\nc.increment();\nconsole.log(\"count:\", c.count);\n// console.log(c.#max); // SyntaxError! Actually private",
    "exercise": "Create a BankAccount class with a public owner field and a truly private #balance field. Add a deposit(amount) method.",
    "hint": "class BankAccount { #balance = 0; ... }",
    "solution": "class BankAccount {\n  owner;\n  #balance = 0;\n  constructor(owner) { this.owner = owner; }\n  deposit(amount) { this.#balance += amount; }\n  toString() { return this.owner + \": $\" + this.#balance; }\n}\nconst acct = new BankAccount(\"Alice\");\nacct.deposit(100);\nconsole.log(String(acct));"
  },
  {
    "es": "ES2022",
    "id": "at-method",
    "title": "at() & Error.cause",
    "description": "Access array/string elements from the end using negative indices. No more arr[arr.length - 1].\n[Proposal](https://github.com/tc39/proposal-relative-indexing-method)",
    "starterCode": "// Problem: getting the last element is awkward\nconst colors = [\"red\", \"green\", \"blue\", \"yellow\"];\n\n// Before:\nconsole.log(\"last old:\", colors[colors.length - 1]);\nconsole.log(\"2nd last:\", colors[colors.length - 2]);\n\n// ES2022: negative indexing with .at()\nconsole.log(\"last new:\", colors.at(-1));\nconsole.log(\"2nd last:\", colors.at(-2));\nconsole.log(\"first:\", colors.at(0));\n\n// Works on strings too\nconst word = \"JavaScript\";\nconsole.log(\"last char:\", word.at(-1));",
    "exercise": "Get the last element of [\"mon\",\"tue\",\"wed\",\"thu\",\"fri\"] using .at().",
    "hint": "arr.at(-1)",
    "solution": "const days = [\"mon\", \"tue\", \"wed\", \"thu\", \"fri\"];\nconsole.log(\"last day:\", days.at(-1));\nconsole.log(\"second:\", days.at(1));"
  },
  {
    "es": "ES2022",
    "id": "hasown-structuredclone",
    "title": "Object.hasOwn & structuredClone",
    "description": "Object.hasOwn replaces the clunky hasOwnProperty call. structuredClone does deep copies.\n[hasOwn](https://github.com/tc39/proposal-accessible-object-hasownproperty) | [structuredClone](https://github.com/nicolo-ribaudo/tc39-proposal-structuredClone)",
    "starterCode": "// Problem 1: checking own properties was verbose\nconst user = { name: \"Alice\", role: \"admin\" };\n\n// Before:\nconsole.log(\"old:\", Object.prototype.hasOwnProperty.call(user, \"name\"));\n\n// ES2022:\nconsole.log(\"new:\", Object.hasOwn(user, \"name\"));\nconsole.log(\"inherited?\", Object.hasOwn(user, \"toString\")); // false\n\n// Problem 2: deep cloning objects\nconst original = { a: 1, nested: { b: 2 }, date: new Date() };\n\n// Before: JSON loses types, spread is shallow\nconst shallow = { ...original };\nshallow.nested.b = 99;\nconsole.log(\"original broken:\", original.nested.b); // 99!\n\n// ES2022: structuredClone does deep copy\nconst deep = structuredClone({ a: 1, nested: { b: 2 } });\ndeep.nested.b = 99;\nconsole.log(\"original safe:\", deep.nested.b);",
    "exercise": "Deep clone an object with nested arrays and verify changing the clone does not affect the original.",
    "hint": "const clone = structuredClone(obj)",
    "solution": "const original = { tags: [\"js\", \"es2022\"], meta: { v: 1 } };\nconst clone = structuredClone(original);\nclone.tags.push(\"new\");\nconsole.log(\"original:\", original.tags.length); // 2\nconsole.log(\"clone:\", clone.tags.length); // 3"
  },
  {
    "es": "ES2022",
    "id": "regexp-match-indices",
    "title": "RegExp Match Indices (/d flag)",
    "description": "Get the start and end positions of regex matches and capture groups with the d flag.\n[Proposal](https://github.com/tc39/proposal-regexp-match-indices)",
    "starterCode": "// Problem: regex tells you WHAT matched but not WHERE\nconst text = \"date: 2024-05\";\n\n// Before: only get the matched string\nconst old = text.match(/(\\d{4})-(\\d{2})/);\nconsole.log(\"match:\", old[0]); // \"2024-05\"\n// but where does \"2024\" start and end? unknown\n\n// ES2022: d flag adds .indices\nconst re = /(\\d{4})-(\\d{2})/d;\nconst m = text.match(re);\nconsole.log(\"full match at:\", m.indices[0]);  // [6, 13]\nconsole.log(\"year at:\", m.indices[1]);        // [6, 10]\nconsole.log(\"month at:\", m.indices[2]);       // [11, 13]",
    "exercise": "Use the d flag to find where an email domain starts and ends in \"contact user@test.com today\".",
    "hint": "Use a capture group for the domain part and check .indices",
    "solution": "const text = \"contact user@test.com today\";\nconst re = /@([\\w.]+)/d;\nconst m = text.match(re);\nconsole.log(\"domain:\", m[1]);\nconsole.log(\"position:\", m.indices[1]);"
  },
  {
    "es": "ES2022",
    "id": "top-level-await",
    "title": "Top-level await",
    "description": "Use await directly at the top level of a module. No more wrapping in async functions.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-await.ops)",
    "starterCode": "// Problem: you needed to wrap everything in an async IIFE\n// Before:\n(async () => {\n  const data = await Promise.resolve(\"loaded config\");\n  console.log(\"old:\", data);\n})();\n\n// ES2022: just use await directly (in modules)\nconst config = await Promise.resolve({ theme: \"dark\", lang: \"en\" });\nconsole.log(\"new:\", config);\n\n// Great for dynamic imports and setup\nconst delay = ms => new Promise(r => setTimeout(r, ms));\nawait delay(10);\nconsole.log(\"module ready\");",
    "exercise": "Use top-level await to \"fetch\" a config value from a Promise and log it.",
    "hint": "const value = await Promise.resolve(...)",
    "solution": "const settings = await Promise.resolve({ debug: true, port: 8080 });\nconsole.log(\"port:\", settings.port);\nconsole.log(\"debug:\", settings.debug);"
  },
  {
    "es": "ES2021",
    "id": "logical-assign",
    "title": "Logical Assignment",
    "description": "Combine logical operators with assignment: ||=, &&=, ??=. Set defaults and guards in one expression.\n[Proposal](https://github.com/tc39/proposal-logical-assignment)",
    "starterCode": "// Problem: setting defaults requires if-statements or ternaries\nlet config = { theme: \"\", retries: 0, name: null };\n\n// Before:\nif (!config.theme) config.theme = \"dark\";\nif (config.name === null || config.name === undefined) config.name = \"anon\";\n\n// ES2021: logical assignment operators\nlet opts = { theme: \"\", retries: 0, name: null };\n\nopts.theme ||= \"dark\";    // assigns if falsy (empty string)\nopts.retries ??= 3;       // assigns if null/undefined (keeps 0!)\nopts.name ??= \"anon\";     // assigns if null\n\nconsole.log(\"theme:\", opts.theme);    // \"dark\"\nconsole.log(\"retries:\", opts.retries); // 0 (preserved!)\nconsole.log(\"name:\", opts.name);       // \"anon\"",
    "exercise": "Given let user = { name: null, score: 0 }, use ??= to set name to \"Guest\" and ||= to set score to 100. Which one preserves 0?",
    "hint": "??= only assigns on null/undefined, ||= assigns on any falsy",
    "solution": "let user = { name: null, score: 0 };\nuser.name ??= \"Guest\";\nuser.score ||= 100;\nconsole.log(user.name);  // \"Guest\"\nconsole.log(user.score); // 100 (0 was falsy!)\n\n// To keep 0, use ??= instead:\nlet user2 = { score: 0 };\nuser2.score ??= 100;\nconsole.log(\"preserved:\", user2.score); // 0"
  },
  {
    "es": "ES2021",
    "id": "replaceall",
    "title": "String.replaceAll",
    "description": "Replace all occurrences of a substring. No more regex with /g flag for simple replacements.\n[Proposal](https://github.com/tc39/proposal-string-replaceall)",
    "starterCode": "// Problem: replace() only replaces the FIRST match\nconst csv = \"a,b,c,d,e\";\n\n// Before: need regex with global flag\nconsole.log(\"old:\", csv.replace(/,/g, \" | \"));\n\n// ES2021: replaceAll just works\nconsole.log(\"new:\", csv.replaceAll(\",\", \" | \"));\n\n// Before was error-prone:\nconst path = \"src/components/Button/index.js\";\nconsole.log(\"old:\", path.replace(/\\//g, \".\")); // regex escaping!\nconsole.log(\"new:\", path.replaceAll(\"/\", \".\"));",
    "exercise": "Replace all spaces in \"hello world foo bar\" with dashes using replaceAll.",
    "hint": "str.replaceAll(\" \", \"-\")",
    "solution": "const str = \"hello world foo bar\";\nconsole.log(str.replaceAll(\" \", \"-\"));"
  },
  {
    "es": "ES2021",
    "id": "promise-any",
    "title": "Promise.any & AggregateError",
    "description": "Resolves as soon as ANY promise succeeds. Unlike Promise.race, it ignores rejections.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-promise-any)",
    "starterCode": "// Problem: you want the fastest successful result\nconst fast = ms => new Promise(r => setTimeout(() => r(ms + \"ms\"), ms));\nconst fail = () => Promise.reject(new Error(\"down\"));\n\n// Before: Promise.race fails if the fastest rejects\n// Promise.race([fail(), fast(50)]); // rejects!\n\n// ES2021: Promise.any ignores failures, resolves with first success\nconst result = await Promise.any([fail(), fast(50), fast(100)]);\nconsole.log(\"fastest success:\", result);\n\n// All fail? You get AggregateError\ntry {\n  await Promise.any([fail(), fail()]);\n} catch (e) {\n  console.log(\"all failed:\", e.constructor.name);\n  console.log(\"errors:\", e.errors.length);\n}",
    "exercise": "Use Promise.any to get the first resolved value from three promises that resolve at different speeds.",
    "hint": "Promise.any([p1, p2, p3])",
    "solution": "const p1 = new Promise(r => setTimeout(() => r(\"slow\"), 300));\nconst p2 = new Promise(r => setTimeout(() => r(\"medium\"), 200));\nconst p3 = new Promise(r => setTimeout(() => r(\"fast\"), 100));\nconst winner = await Promise.any([p1, p2, p3]);\nconsole.log(\"winner:\", winner);"
  },
  {
    "es": "ES2021",
    "id": "numeric-separators",
    "title": "Numeric Separators & WeakRef",
    "description": "Use underscores in numbers for readability. No effect on the value.\n[Proposal](https://github.com/tc39/proposal-numeric-separator)",
    "starterCode": "// Problem: large numbers are hard to read\n// Before:\nconst revenue = 1000000000;\nconst color = 0xFF2D00;\n\n// Is that a billion? A hundred million? Hard to tell\n\n// ES2021: underscores as visual separators\nconst revenue2 = 1_000_000_000;\nconst color2 = 0xFF_2D_00;\nconst binary = 0b1010_0001_1000;\nconst bytes = 1_073_741_824; // 1 GB in bytes\n\nconsole.log(\"same value?\", revenue === revenue2);\nconsole.log(\"revenue:\", revenue2);\nconsole.log(\"color:\", color2.toString(16));\nconsole.log(\"bytes:\", bytes);",
    "exercise": "Write one billion bytes (1073741824) using numeric separators to make it readable.",
    "hint": "Group digits in threes: 1_073_741_824",
    "solution": "const oneGB = 1_073_741_824;\nconst price = 49_999.99;\nconsole.log(\"1 GB:\", oneGB);\nconsole.log(\"price:\", price);"
  },
  {
    "es": "ES2020",
    "id": "optional-chain",
    "title": "Optional Chaining",
    "description": "Access deeply nested properties without checking each level. Returns undefined instead of throwing.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-optional-chaining)",
    "starterCode": "// Problem: accessing nested properties can throw\nconst user = { name: \"Alice\", address: { city: \"NYC\" } };\nconst guest = { name: \"Bob\" };\n\n// Before: check every level\nconst city1 = guest.address && guest.address.city ? guest.address.city : \"unknown\";\nconsole.log(\"old:\", city1);\n\n// ES2020: ?. stops at null/undefined\nconsole.log(\"new:\", guest?.address?.city ?? \"unknown\");\nconsole.log(\"deep:\", user?.address?.city);\n\n// Works with methods and arrays too\nconst arr = [1, 2, 3];\nconsole.log(\"method:\", guest?.greet?.());   // undefined (no crash)\nconsole.log(\"array:\", arr?.[10]);            // undefined",
    "exercise": "Safely access user?.settings?.theme from an object where settings might not exist. Default to \"light\".",
    "hint": "user?.settings?.theme ?? \"light\"",
    "solution": "const user = { name: \"Alice\" }; // no settings\nconst theme = user?.settings?.theme ?? \"light\";\nconsole.log(\"theme:\", theme);"
  },
  {
    "es": "ES2020",
    "id": "nullish-coalesce",
    "title": "Nullish Coalescing",
    "description": "The ?? operator provides defaults only for null/undefined, not for 0 or empty strings.\n[Proposal](https://github.com/tc39/proposal-nullish-coalescing)",
    "starterCode": "// Problem: || treats 0, \"\", and false as \"missing\"\nconst port = 0;\nconst name = \"\";\nconst debug = false;\n\n// Before: || gives wrong defaults\nconsole.log(\"old port:\", port || 3000);   // 3000 (wrong! 0 is valid)\nconsole.log(\"old name:\", name || \"anon\"); // \"anon\" (wrong! \"\" is valid)\n\n// ES2020: ?? only triggers on null/undefined\nconsole.log(\"new port:\", port ?? 3000);   // 0 (correct!)\nconsole.log(\"new name:\", name ?? \"anon\"); // \"\" (correct!)\nconsole.log(\"new debug:\", debug ?? true); // false (correct!)\n\nconst missing = null;\nconsole.log(\"null:\", missing ?? \"default\"); // \"default\"",
    "exercise": "Given a config where volume might be 0 (muted), use ?? to default to 50 only when volume is null/undefined.",
    "hint": "config.volume ?? 50",
    "solution": "const config = { volume: 0, brightness: null };\nconsole.log(\"volume:\", config.volume ?? 50);        // 0 (muted)\nconsole.log(\"brightness:\", config.brightness ?? 75); // 75"
  },
  {
    "es": "ES2020",
    "id": "bigint",
    "title": "BigInt",
    "description": "BigInt handles integers larger than Number.MAX_SAFE_INTEGER without losing precision.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-bigint)",
    "starterCode": "// Problem: JavaScript loses precision with large numbers\nconst big = 9007199254740992;\nconsole.log(\"before:\", big === big + 1); // true! Precision lost\n\n// ES2020: BigInt handles arbitrary precision\nconst safe = 9007199254740992n;\nconsole.log(\"safe:\", safe === safe + 1n); // false (correct!)\n\n// Arithmetic works as expected\nconsole.log(\"add:\", 100n + 200n);\nconsole.log(\"power:\", 2n ** 64n);\n\n// Note: can't mix BigInt and Number\n// console.log(10n + 5); // TypeError!\nconsole.log(\"convert:\", Number(42n) + 5);",
    "exercise": "Calculate 2^100 using BigInt. Regular numbers can not represent this accurately.",
    "hint": "Use 2n ** 100n",
    "solution": "const result = 2n ** 100n;\nconsole.log(\"2^100:\", result);\nconsole.log(\"digits:\", result.toString().length);"
  },
  {
    "es": "ES2020",
    "id": "promise-allsettled",
    "title": "Promise.allSettled & globalThis",
    "description": "Wait for ALL promises to finish (success or failure), unlike Promise.all which short-circuits on the first rejection.\n[Proposal](https://github.com/tc39/proposal-promise-allSettled)",
    "starterCode": "// Problem: Promise.all fails fast on any rejection\nconst ok = v => Promise.resolve(v);\nconst fail = e => Promise.reject(new Error(e));\n\n// Before: one failure kills everything\n// await Promise.all([ok(\"a\"), fail(\"b\"), ok(\"c\")]); // throws!\n\n// ES2020: allSettled always completes\nconst results = await Promise.allSettled([\n  ok(\"user data\"),\n  fail(\"network error\"),\n  ok(\"cache hit\"),\n]);\nresults.forEach(r => {\n  if (r.status === \"fulfilled\") console.log(\"ok:\", r.value);\n  else console.log(\"fail:\", r.reason.message);\n});",
    "exercise": "Use Promise.allSettled to fetch 3 resources (some might fail). Log the status of each.",
    "hint": "Check r.status === \"fulfilled\" vs \"rejected\"",
    "solution": "const results = await Promise.allSettled([\n  Promise.resolve(\"data\"),\n  Promise.reject(new Error(\"timeout\")),\n  Promise.resolve(\"more data\"),\n]);\nresults.forEach((r, i) => console.log(i + \":\", r.status));"
  },
  {
    "es": "ES2020",
    "id": "matchall-dynamic-import",
    "title": "matchAll & Dynamic import()",
    "description": "matchAll returns all regex matches with groups. Dynamic import() loads modules on demand.\n[matchAll](https://github.com/tc39/proposal-string-matchall) | [import()](https://github.com/nicolo-ribaudo/tc39-proposal-dynamic-import)",
    "starterCode": "// Problem: finding all regex matches required a loop\nconst text = \"price: $10, tax: $2, total: $12\";\n\n// Before: loop with exec\nconst re = /\\$(\\d+)/g;\nlet m; const oldResults = [];\nwhile ((m = re.exec(text)) !== null) oldResults.push(m[1]);\nconsole.log(\"old:\", oldResults);\n\n// ES2020: matchAll returns an iterator of all matches\nconst matches = [...text.matchAll(/\\$(\\d+)/g)];\nconst amounts = matches.map(m => m[1]);\nconsole.log(\"new:\", amounts);\n\n// Each match includes groups and index\nconsole.log(\"first match at:\", matches[0].index);",
    "exercise": "Extract all hashtags from \"I love #javascript and #coding today #es2020\" using matchAll.",
    "hint": "str.matchAll(/#(\\w+)/g)",
    "solution": "const text = \"I love #javascript and #coding today #es2020\";\nconst tags = [...text.matchAll(/#(\\w+)/g)].map(m => m[1]);\nconsole.log(\"tags:\", tags);"
  },
  {
    "es": "ES2019",
    "id": "flat-flatmap",
    "title": "flat & flatMap",
    "description": "Flatten nested arrays, or map and flatten in one step.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-flatmap)",
    "starterCode": "// Problem: working with nested arrays\nconst nested = [[1, 2], [3, 4], [5]];\n\n// Before: concat + spread\nconst oldFlat = [].concat(...nested);\nconsole.log(\"old:\", oldFlat);\n\n// ES2019: .flat()\nconsole.log(\"flat:\", nested.flat());\nconsole.log(\"deep:\", [1, [2, [3, [4]]]].flat(Infinity));\n\n// flatMap: map then flatten (1 level)\nconst sentences = [\"hello world\", \"foo bar\"];\n\n// Before: map then flat\nconst oldWords = sentences.map(s => s.split(\" \")).flat();\nconsole.log(\"old words:\", oldWords);\n\n// ES2019: one step\nconsole.log(\"new words:\", sentences.flatMap(s => s.split(\" \")));",
    "exercise": "Given [\"1,2\", \"3,4\", \"5,6\"], use flatMap to get [1,2,3,4,5,6] as numbers.",
    "hint": "s.split(\",\").map(Number) inside flatMap",
    "solution": "const data = [\"1,2\", \"3,4\", \"5,6\"];\nconst nums = data.flatMap(s => s.split(\",\").map(Number));\nconsole.log(nums);"
  },
  {
    "es": "ES2019",
    "id": "from-entries",
    "title": "Object.fromEntries",
    "description": "Convert key-value pairs back into an object. The inverse of Object.entries().\n[Proposal](https://github.com/tc39/proposal-object-from-entries)",
    "starterCode": "// Problem: transforming object properties requires converting back\nconst prices = { apple: 1.2, banana: 0.5, cherry: 2.0 };\n\n// Before: reduce to rebuild the object\nconst doubled = Object.entries(prices).reduce((obj, [k, v]) => {\n  obj[k] = v * 2;\n  return obj;\n}, {});\nconsole.log(\"old:\", doubled);\n\n// ES2019: entries + map + fromEntries\nconst taxed = Object.fromEntries(\n  Object.entries(prices).map(([k, v]) => [k, +(v * 1.1).toFixed(2)])\n);\nconsole.log(\"new:\", taxed);\n\n// Also great for converting Maps to objects\nconst m = new Map([[\"x\", 1], [\"y\", 2]]);\nconsole.log(\"map->obj:\", Object.fromEntries(m));",
    "exercise": "Convert URLSearchParams(\"a=1&b=2&c=3\") into a plain object using Object.fromEntries.",
    "hint": "new URLSearchParams(\"...\") is iterable",
    "solution": "const params = new URLSearchParams(\"a=1&b=2&c=3\");\nconst obj = Object.fromEntries(params);\nconsole.log(obj);"
  },
  {
    "es": "ES2019",
    "id": "trimstart-catch",
    "title": "trimStart/trimEnd & Optional catch",
    "description": "Trim whitespace from one side of a string. Also: catch blocks no longer require a parameter.\n[trimStart/End](https://github.com/tc39/proposal-string-left-right-trim) | [optional catch](https://github.com/tc39/proposal-optional-catch-binding)",
    "starterCode": "// Problem: trim() removes both sides, sometimes you want just one\nconst input = \"   hello   \";\n\n// Before: regex or substring\nconsole.log(\"old:\", input.replace(/^\\s+/, \"\") + \"|\");\n\n// ES2019: trimStart and trimEnd\nconsole.log(\"trimStart:\", input.trimStart() + \"|\");\nconsole.log(\"trimEnd:\", \"|\" + input.trimEnd());\n\n// Optional catch binding\n// Before: forced to declare error even if unused\ntry { JSON.parse(\"bad\"); } catch (err) { console.log(\"old: caught\"); }\n\n// ES2019: skip the parameter\ntry { JSON.parse(\"bad\"); } catch { console.log(\"new: caught (no param)\"); }",
    "exercise": "Clean up user input \"  alice  \" by trimming only the start.",
    "hint": "str.trimStart()",
    "solution": "const input = \"  alice  \";\nconst cleaned = input.trimStart();\nconsole.log(\"|\" + cleaned + \"|\");"
  },
  {
    "es": "ES2019",
    "id": "json-stable-sort",
    "title": "JSON superset & Stable Sort",
    "description": "JSON now accepts special Unicode characters, and Array.sort is guaranteed to be stable.\n[JSON superset](https://github.com/nicolo-ribaudo/tc39-proposal-JSON-superset) | [Stable sort](https://github.com/nicolo-ribaudo/tc39-proposal-stable-sort)",
    "starterCode": "// Problem 1: JSON.parse choked on U+2028 and U+2029\nconst json = '{\"text\": \"line1\\u2028line2\"}';\nconst parsed = JSON.parse(json);\nconsole.log(\"parsed:\", parsed.text.length, \"chars\");\n\n// Problem 2: Array.sort was not guaranteed to be stable\n// \"Stable\" means equal elements keep their original order\nconst students = [\n  { name: \"A\", grade: \"B\" },\n  { name: \"B\", grade: \"A\" },\n  { name: \"C\", grade: \"B\" },\n  { name: \"D\", grade: \"A\" },\n];\n\n// ES2019: sort is now guaranteed stable\nconst byGrade = students.toSorted((a, b) => a.grade.localeCompare(b.grade));\nconsole.log(\"stable sort:\", byGrade.map(s => s.name));\n// A and B grades keep relative order within each group",
    "exercise": "Sort [{name:\"Z\",age:25},{name:\"A\",age:25},{name:\"M\",age:30}] by age. Verify same-age items keep their original order.",
    "hint": "Sort by age only. Z and A should stay in that order since both are 25.",
    "solution": "const people = [{name:\"Z\",age:25},{name:\"A\",age:25},{name:\"M\",age:30}];\nconst sorted = people.toSorted((a, b) => a.age - b.age);\nconsole.log(sorted.map(p => p.name)); // [\"Z\",\"A\",\"M\"]"
  },
  {
    "es": "ES2018",
    "id": "obj-rest-spread",
    "title": "Object Rest/Spread",
    "description": "Spread and rest syntax for objects. Clone, merge, and pick properties cleanly.\n[Proposal](https://github.com/tc39/proposal-object-rest-spread)",
    "starterCode": "// Problem: merging objects and extracting properties\nconst defaults = { theme: \"dark\", lang: \"en\", debug: false };\nconst userPrefs = { theme: \"light\", fontSize: 14 };\n\n// Before: Object.assign mutates the target\nconst old = Object.assign({}, defaults, userPrefs);\nconsole.log(\"old:\", old);\n\n// ES2018: object spread (no mutation)\nconst config = { ...defaults, ...userPrefs };\nconsole.log(\"new:\", config);\n\n// Rest: extract some properties, collect the rest\nconst { debug, ...clean } = config;\nconsole.log(\"clean:\", clean);\nconsole.log(\"removed:\", debug);",
    "exercise": "Extract \"password\" from {user:\"alice\",password:\"secret\",role:\"admin\"} into a separate variable. Keep the rest.",
    "hint": "const { password, ...safe } = obj",
    "solution": "const data = { user: \"alice\", password: \"secret\", role: \"admin\" };\nconst { password, ...safe } = data;\nconsole.log(\"safe:\", safe);\nconsole.log(\"removed:\", password);"
  },
  {
    "es": "ES2018",
    "id": "async-iteration",
    "title": "Async Iteration",
    "description": "Use for-await-of to loop over async data sources that yield values over time.\n[Proposal](https://github.com/tc39/proposal-async-iteration)",
    "starterCode": "// Problem: processing items that arrive over time (streams, APIs)\n// Before: manual promise chaining or callbacks\n\n// ES2018: for-await-of with async iterables\nasync function* countdown(n) {\n  while (n > 0) {\n    await new Promise(r => setTimeout(r, 10));\n    yield n--;\n  }\n}\n\n// Consume async values cleanly\nfor await (const num of countdown(3)) {\n  console.log(\"tick:\", num);\n}\nconsole.log(\"done!\");",
    "exercise": "Create an async iterable that yields 3 user names with a small delay between each.",
    "hint": "async function* getUsers() { yield ...; }",
    "solution": "async function* getUsers() {\n  const names = [\"Alice\", \"Bob\", \"Charlie\"];\n  for (const name of names) {\n    await new Promise(r => setTimeout(r, 10));\n    yield name;\n  }\n}\nfor await (const user of getUsers()) {\n  console.log(\"user:\", user);\n}"
  },
  {
    "es": "ES2018",
    "id": "promise-finally",
    "title": "Promise.finally & Named Captures",
    "description": "Run cleanup code after a promise settles, whether it resolved or rejected.\n[finally](https://github.com/tc39/proposal-promise-finally) | [named captures](https://github.com/tc39/proposal-regexp-named-groups)",
    "starterCode": "// Problem: cleanup logic duplicated in both then and catch\n// Before:\nlet loading = true;\nawait Promise.resolve(\"data\")\n  .then(v => { loading = false; console.log(\"old ok:\", v); })\n  .catch(e => { loading = false; console.log(\"old err:\", e); });\n\n// ES2018: finally runs regardless of outcome\nloading = true;\nawait Promise.resolve(\"data\")\n  .then(v => console.log(\"got:\", v))\n  .catch(e => console.log(\"err:\", e))\n  .finally(() => { loading = false; console.log(\"cleanup done, loading:\", loading); });\n\n// Named capture groups\nconst dateRe = /(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})/;\nconst m = \"2024-05-04\".match(dateRe);\nconsole.log(\"year:\", m.groups.year);",
    "exercise": "Write a promise chain with .finally() that logs \"request complete\" regardless of success or failure.",
    "hint": ".then(...).catch(...).finally(() => console.log(...))",
    "solution": "await Promise.reject(new Error(\"timeout\"))\n  .then(v => console.log(\"ok:\", v))\n  .catch(e => console.log(\"err:\", e.message))\n  .finally(() => console.log(\"request complete\"));"
  },
  {
    "es": "ES2017",
    "id": "async-await",
    "title": "async/await",
    "description": "Write asynchronous code that reads like synchronous code. No more .then() chains.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-async-await)",
    "starterCode": "// Problem: promise chains are hard to read\nfunction fetchUser(id) {\n  return new Promise(r => setTimeout(() => r({ id, name: \"Alice\" }), 50));\n}\nfunction fetchPosts(userId) {\n  return new Promise(r => setTimeout(() => r([\"post1\", \"post2\"]), 50));\n}\n\n// Before: nested .then() chains\nfetchUser(1)\n  .then(user => fetchPosts(user.id)\n    .then(posts => console.log(\"old:\", user.name, posts)));\n\n// ES2017: async/await reads top-to-bottom\nasync function loadProfile() {\n  const user = await fetchUser(1);\n  const posts = await fetchPosts(user.id);\n  console.log(\"new:\", user.name, posts);\n}\nawait loadProfile();",
    "exercise": "Write an async function that awaits two promises sequentially and logs both results.",
    "hint": "async function run() { const a = await p1; const b = await p2; }",
    "solution": "async function run() {\n  const name = await Promise.resolve(\"Alice\");\n  const age = await Promise.resolve(30);\n  console.log(name, \"is\", age);\n}\nawait run();"
  },
  {
    "es": "ES2017",
    "id": "object-entries",
    "title": "Object.entries/values/getOwnPropertyDescriptors",
    "description": "Get an object's key-value pairs as an array. Also: Object.values and getOwnPropertyDescriptors.\n[entries/values](https://github.com/tc39/proposal-object-values-entries) | [descriptors](https://github.com/nicolo-ribaudo/tc39-proposal-getOwnPropertyDescriptors)",
    "starterCode": "// Problem: iterating over object properties\nconst config = { host: \"localhost\", port: 3000, debug: true };\n\n// Before: Object.keys + manual value lookup\nObject.keys(config).forEach(k => console.log(\"old:\", k, \"=\", config[k]));\n\n// ES2017: Object.entries gives [key, value] pairs\nfor (const [key, val] of Object.entries(config)) {\n  console.log(\"entry:\", key, \"=\", val);\n}\n\n// Object.values: just the values\nconsole.log(\"values:\", Object.values(config));\n\n// Useful for transformations\nconst upper = Object.fromEntries(\n  Object.entries(config).map(([k, v]) => [k.toUpperCase(), v])\n);\nconsole.log(\"upper keys:\", upper);",
    "exercise": "Convert {a:1, b:2, c:3} to an array of strings like [\"a=1\", \"b=2\", \"c=3\"] using Object.entries.",
    "hint": "Object.entries(obj).map(([k,v]) => k + \"=\" + v)",
    "solution": "const obj = { a: 1, b: 2, c: 3 };\nconst result = Object.entries(obj).map(([k, v]) => k + \"=\" + v);\nconsole.log(result);"
  },
  {
    "es": "ES2017",
    "id": "pad-trailing",
    "title": "padStart/padEnd & Trailing Commas",
    "description": "Pad strings to a fixed length. Useful for formatting tables, IDs, and aligned output.\n[padStart/End](https://github.com/tc39/proposal-string-pad-start-end)",
    "starterCode": "// Problem: formatting strings to a fixed width\n// Before: manual string concatenation or slice tricks\n\n// ES2017: padStart and padEnd\nconsole.log(\"5\".padStart(3, \"0\"));      // \"005\"\nconsole.log(\"42\".padStart(5, \" \"));     // \"   42\"\nconsole.log(\"hi\".padEnd(10, \".\"));      // \"hi........\"\n\n// Real-world: format a price list\nconst items = [[\"Coffee\", 4.5], [\"Sandwich\", 8], [\"Cookie\", 2.25]];\nfor (const [name, price] of items) {\n  const line = name.padEnd(12, \".\") + \"$\" + price.toFixed(2).padStart(6);\n  console.log(line);\n}\n\n// Trailing commas in function parameters (also ES2017)\nfunction greet(name, greeting,) { // trailing comma ok\n  console.log(greeting, name);\n}\ngreet(\"world\", \"hello\",); // trailing comma in call too",
    "exercise": "Format the number 7 as a 4-digit zero-padded string (like \"0007\").",
    "hint": "String(7).padStart(4, \"0\")",
    "solution": "const id = 7;\nconst formatted = String(id).padStart(4, \"0\");\nconsole.log(formatted);"
  },
  {
    "es": "ES2016",
    "id": "array-includes",
    "title": "Array.includes",
    "description": "Check if an array contains a value. Simpler and more correct than indexOf.\n[Proposal](https://github.com/nicolo-ribaudo/tc39-proposal-array-includes)",
    "starterCode": "// Problem: checking if an item exists in an array\nconst fruits = [\"apple\", \"banana\", \"mango\"];\n\n// Before: indexOf returns -1 for missing (confusing!)\nconsole.log(\"old:\", fruits.indexOf(\"banana\") !== -1); // true\nconsole.log(\"old:\", fruits.indexOf(\"grape\") !== -1);  // false\n\n// ES2016: .includes() returns true/false directly\nconsole.log(\"new:\", fruits.includes(\"banana\")); // true\nconsole.log(\"new:\", fruits.includes(\"grape\"));  // false\n\n// includes() handles NaN correctly (indexOf doesn't!)\nconst nums = [1, 2, NaN, 4];\nconsole.log(\"indexOf NaN:\", nums.indexOf(NaN));   // -1 (wrong!)\nconsole.log(\"includes NaN:\", nums.includes(NaN)); // true (correct!)",
    "exercise": "Check if the color \"red\" is in [\"blue\", \"green\", \"red\", \"yellow\"]. Use includes.",
    "hint": "arr.includes(\"red\")",
    "solution": "const colors = [\"blue\", \"green\", \"red\", \"yellow\"];\nconsole.log(\"has red:\", colors.includes(\"red\"));\nconsole.log(\"has pink:\", colors.includes(\"pink\"));"
  },
  {
    "es": "ES2016",
    "id": "exponentiation",
    "title": "Exponentiation Operator",
    "description": "The ** operator replaces Math.pow for cleaner exponentiation.\n[Proposal](https://github.com/tc39/proposal-exponentiation-operator)",
    "starterCode": "// Problem: Math.pow is verbose for simple math\n// Before:\nconsole.log(\"old:\", Math.pow(2, 10)); // 1024\nconsole.log(\"old:\", Math.pow(5, 3));  // 125\n\n// ES2016: ** operator\nconsole.log(\"new:\", 2 ** 10); // 1024\nconsole.log(\"new:\", 5 ** 3);  // 125\n\n// Works with assignment\nlet base = 3;\nbase **= 4;\nconsole.log(\"3^4:\", base); // 81\n\n// Cleaner in formulas\nconst area = 3.14 * 5 ** 2; // pi * r^2\nconsole.log(\"circle area:\", area.toFixed(2));",
    "exercise": "Calculate 2^16 using the ** operator.",
    "hint": "2 ** 16",
    "solution": "console.log(\"2^16:\", 2 ** 16);\nconsole.log(\"10^6:\", 10 ** 6);"
  },
  {
    "es": "ES2015",
    "id": "let-const",
    "title": "let & const",
    "description": "Block-scoped variables that replace var. const prevents reassignment, let allows it.\n[Spec](https://262.ecma-international.org/6.0/#sec-let-and-const-declarations)",
    "starterCode": "// Problem: var is function-scoped, leaks out of blocks\nif (true) { var x = 1; }\nconsole.log(\"var leaked:\", x); // 1 (accessible outside if!)\n\n// ES2015: let and const are block-scoped\nif (true) { let y = 2; }\n// console.log(y); // ReferenceError! y stays in the block\n\n// const prevents reassignment\nconst PI = 3.14;\n// PI = 3; // TypeError!\nconsole.log(\"PI:\", PI);\n\n// Classic bug with var in loops\nfor (var i = 0; i < 3; i++) {}\nconsole.log(\"var i leaked:\", i); // 3\n\nfor (let j = 0; j < 3; j++) {}\n// console.log(j); // ReferenceError! j stays in the loop",
    "exercise": "Declare a constant MAX_SIZE set to 100. Try to reassign it and observe the error.",
    "hint": "const MAX_SIZE = 100; MAX_SIZE = 200; // what happens?",
    "solution": "const MAX_SIZE = 100;\nconsole.log(\"MAX_SIZE:\", MAX_SIZE);\n// MAX_SIZE = 200; // Would throw TypeError\n// Note: const objects CAN be mutated:\nconst arr = [1, 2]; arr.push(3);\nconsole.log(\"mutated array:\", arr);"
  },
  {
    "es": "ES2015",
    "id": "arrow-fn",
    "title": "Arrow Functions",
    "description": "Shorter function syntax that also inherits \"this\" from the surrounding scope.\n[Spec](https://262.ecma-international.org/6.0/#sec-arrow-function-definitions)",
    "starterCode": "// Problem: function expressions are verbose and \"this\" is confusing\n// Before:\nconst numbers = [1, 2, 3];\nconst doubled = numbers.map(function(n) { return n * 2; });\nconsole.log(\"old:\", doubled);\n\n// ES2015: arrow functions\nconst tripled = numbers.map(n => n * 3);\nconsole.log(\"new:\", tripled);\n\n// Single expression = implicit return\nconst add = (a, b) => a + b;\nconsole.log(\"add:\", add(2, 3));\n\n// Multi-line needs braces and return\nconst describe = name => {\n  const upper = name.toUpperCase();\n  return \"Hello, \" + upper;\n};\nconsole.log(describe(\"world\"));",
    "exercise": "Rewrite function(a, b) { return a * b; } as an arrow function.",
    "hint": "(a, b) => a * b",
    "solution": "const multiply = (a, b) => a * b;\nconsole.log(multiply(4, 5));\nconsole.log(multiply(3, 7));"
  },
  {
    "es": "ES2015",
    "id": "template-lit",
    "title": "Template Literals",
    "description": "String interpolation with backticks. Embed expressions and multi-line strings cleanly.\n[Spec](https://262.ecma-international.org/6.0/#sec-template-literals)",
    "starterCode": "// Problem: building strings with concatenation is messy\nconst name = \"JavaScript\";\nconst year = 2015;\n\n// Before:\nconsole.log(\"old: Hello \" + name + \"! Born in \" + year + \".\");\n\n// ES2015: template literals\nconsole.log(`new: Hello ${name}! Born in ${year}.`);\n\n// Expressions work inside ${}\nconsole.log(`2 + 2 = ${2 + 2}`);\nconsole.log(`upper: ${name.toUpperCase()}`);\n\n// Multi-line strings (no more \\n)\nconst html = `\n  <div>\n    <h1>${name}</h1>\n    <p>Since ${year}</p>\n  </div>`;\nconsole.log(html);",
    "exercise": "Create a template literal that says \"X is Y years old\" using variables.",
    "hint": "`${name} is ${age} years old`",
    "solution": "const name = \"Alice\";\nconst age = 30;\nconsole.log(`${name} is ${age} years old`);"
  },
  {
    "es": "ES2015",
    "id": "destructuring",
    "title": "Destructuring",
    "description": "Extract values from arrays and properties from objects into variables in one step.\n[Spec](https://262.ecma-international.org/6.0/#sec-destructuring-assignment)",
    "starterCode": "// Problem: extracting values is verbose\nconst coords = [40.7, -74.0, \"NYC\"];\n\n// Before:\nconst lat = coords[0];\nconst lng = coords[1];\nconsole.log(\"old:\", lat, lng);\n\n// ES2015: array destructuring\nconst [latitude, longitude, city] = coords;\nconsole.log(\"new:\", latitude, longitude, city);\n\n// Object destructuring\nconst user = { name: \"Alice\", age: 30, role: \"admin\" };\n\n// Before:\nconst userName = user.name;\n\n// ES2015:\nconst { name, age, role } = user;\nconsole.log(\"user:\", name, age, role);\n\n// Rename + defaults\nconst { name: n, country = \"US\" } = user;\nconsole.log(\"renamed:\", n, \"default:\", country);",
    "exercise": "Extract the first and third items from [\"a\",\"b\",\"c\",\"d\"], skipping the second.",
    "hint": "const [first, , third] = arr",
    "solution": "const [first, , third] = [\"a\", \"b\", \"c\", \"d\"];\nconsole.log(first, third);"
  },
  {
    "es": "ES2015",
    "id": "default-params",
    "title": "Default Parameters",
    "description": "Set default values for function parameters. No more manual checking inside the function.\n[Spec](https://262.ecma-international.org/6.0/#sec-function-definitions)",
    "starterCode": "// Problem: handling missing arguments\n// Before:\nfunction greetOld(name, greeting) {\n  name = name || \"World\";         // breaks if name is \"\"!\n  greeting = greeting || \"Hello\";\n  return greeting + \", \" + name;\n}\nconsole.log(\"old:\", greetOld());\n\n// ES2015: default parameters\nfunction greet(name = \"World\", greeting = \"Hello\") {\n  return `${greeting}, ${name}`;\n}\nconsole.log(\"new:\", greet());\nconsole.log(\"custom:\", greet(\"Alice\", \"Hi\"));\nconsole.log(\"partial:\", greet(\"Bob\"));\n\n// Defaults can use previous parameters\nfunction createUser(name, role = \"user\", id = name + \"_\" + role) {\n  return { name, role, id };\n}\nconsole.log(createUser(\"alice\"));",
    "exercise": "Write a function that calculates tax with a default rate of 0.1 (10%).",
    "hint": "function tax(amount, rate = 0.1)",
    "solution": "function tax(amount, rate = 0.1) {\n  return amount * rate;\n}\nconsole.log(\"default:\", tax(100));   // 10\nconsole.log(\"custom:\", tax(100, 0.2)); // 20"
  },
  {
    "es": "ES2015",
    "id": "rest-params",
    "title": "Rest Parameters",
    "description": "Collect remaining arguments into a real array. Replaces the old \"arguments\" object.\n[Spec](https://262.ecma-international.org/6.0/#sec-function-definitions)",
    "starterCode": "// Problem: \"arguments\" is not a real array\nfunction oldSum() {\n  // arguments exists but has no .map, .filter, etc.\n  return Array.prototype.slice.call(arguments).reduce((a, b) => a + b, 0);\n}\nconsole.log(\"old:\", oldSum(1, 2, 3));\n\n// ES2015: rest parameters are real arrays\nfunction sum(...nums) {\n  return nums.reduce((a, b) => a + b, 0);\n}\nconsole.log(\"new:\", sum(1, 2, 3, 4, 5));\n\n// Combine with regular params\nfunction log(level, ...messages) {\n  console.log(`[${level}]`, ...messages);\n}\nlog(\"INFO\", \"server\", \"started\", \"on port 3000\");",
    "exercise": "Write a function that takes a multiplier and any number of values, returning each value multiplied.",
    "hint": "function mult(factor, ...values) { return values.map(v => v * factor); }",
    "solution": "function mult(factor, ...values) {\n  return values.map(v => v * factor);\n}\nconsole.log(mult(2, 1, 2, 3)); // [2, 4, 6]\nconsole.log(mult(10, 5, 10));  // [50, 100]"
  },
  {
    "es": "ES2015",
    "id": "spread-op",
    "title": "Spread Operator",
    "description": "Expand arrays and objects into individual elements. The opposite of rest.\n[Spec](https://262.ecma-international.org/6.0/#sec-array-initializer)",
    "starterCode": "// Problem: combining arrays, passing array items as arguments\nconst a = [1, 2, 3];\nconst b = [4, 5, 6];\n\n// Before:\nconst oldMerge = a.concat(b);\nconsole.log(\"old:\", oldMerge);\nconsole.log(\"old max:\", Math.max.apply(null, a));\n\n// ES2015: spread operator\nconst merged = [...a, ...b];\nconsole.log(\"new:\", merged);\nconsole.log(\"new max:\", Math.max(...a));\n\n// Clone an array (shallow)\nconst clone = [...a];\nclone.push(99);\nconsole.log(\"original:\", a);   // unchanged\nconsole.log(\"clone:\", clone);\n\n// Convert iterables to arrays\nconst chars = [...\"hello\"];\nconsole.log(\"chars:\", chars);",
    "exercise": "Merge two arrays [1,2] and [3,4] with 0 in the middle: [1,2,0,3,4].",
    "hint": "[...a, 0, ...b]",
    "solution": "const a = [1, 2];\nconst b = [3, 4];\nconst result = [...a, 0, ...b];\nconsole.log(result);"
  },
  {
    "es": "ES2015",
    "id": "enhanced-obj",
    "title": "Enhanced Object Literals",
    "description": "Shorter syntax for object literals: shorthand properties, methods, and computed keys.\n[Spec](https://262.ecma-international.org/6.0/#sec-object-initializer)",
    "starterCode": "// Problem: repetitive object creation\nconst name = \"Alice\";\nconst age = 30;\n\n// Before: key and value repeat\nconst oldUser = { name: name, age: age };\nconsole.log(\"old:\", oldUser);\n\n// ES2015: shorthand properties (when key === variable name)\nconst user = { name, age };\nconsole.log(\"new:\", user);\n\n// Shorthand methods (no \"function\" keyword)\nconst calc = {\n  add(a, b) { return a + b; },\n  mul(a, b) { return a * b; },\n};\nconsole.log(\"add:\", calc.add(2, 3));\n\n// Computed property names\nconst field = \"score\";\nconst data = { [field]: 100, [`${field}Label`]: \"points\" };\nconsole.log(\"computed:\", data);",
    "exercise": "Create an object from variables x=10, y=20 using shorthand, and add a computed key \"sum\".",
    "hint": "{ x, y, [\"sum\"]: x + y }",
    "solution": "const x = 10, y = 20;\nconst point = { x, y, [\"sum\"]: x + y };\nconsole.log(point);"
  },
  {
    "es": "ES2015",
    "id": "classes",
    "title": "Classes",
    "description": "A clean syntax for constructor functions and prototypal inheritance.\n[Spec](https://262.ecma-international.org/6.0/#sec-class-definitions)",
    "starterCode": "// Problem: prototype-based OOP is confusing\n// Before:\nfunction OldAnimal(name) { this.name = name; }\nOldAnimal.prototype.speak = function() { return this.name + \" speaks\"; };\nconsole.log(\"old:\", new OldAnimal(\"Rex\").speak());\n\n// ES2015: class syntax\nclass Animal {\n  constructor(name) { this.name = name; }\n  speak() { return `${this.name} makes a sound`; }\n}\n\nclass Dog extends Animal {\n  speak() { return `${this.name} barks`; }\n}\n\nconst dog = new Dog(\"Rex\");\nconsole.log(\"new:\", dog.speak());\nconsole.log(\"is Animal?\", dog instanceof Animal);",
    "exercise": "Create a Shape class with an area() method. Extend it with a Circle class.",
    "hint": "class Circle extends Shape { ... }",
    "solution": "class Shape {\n  area() { return 0; }\n}\nclass Circle extends Shape {\n  constructor(r) { super(); this.r = r; }\n  area() { return Math.PI * this.r ** 2; }\n}\nconsole.log(\"area:\", new Circle(5).area().toFixed(2));"
  },
  {
    "es": "ES2015",
    "id": "iterators-forof",
    "title": "Iterators & for...of",
    "description": "for...of loops over any iterable (arrays, strings, Maps, Sets). The Symbol.iterator protocol.\n[Spec](https://262.ecma-international.org/6.0/#sec-for-in-and-for-of-statements)",
    "starterCode": "// Problem: for...in iterates keys, not values, and includes inherited props\nconst arr = [\"a\", \"b\", \"c\"];\n\n// Before: for...in gives indices (strings!) not values\nfor (const i in arr) console.log(\"for-in:\", typeof i, i);\n\n// ES2015: for...of gives values directly\nfor (const val of arr) console.log(\"for-of:\", val);\n\n// Works on strings\nfor (const ch of \"hi!\") console.log(\"char:\", ch);\n\n// Works on Maps\nconst m = new Map([[\"x\", 1], [\"y\", 2]]);\nfor (const [key, val] of m) console.log(`${key} = ${val}`);\n\n// Works on Sets\nconst s = new Set([1, 2, 3]);\nfor (const v of s) console.log(\"set:\", v);",
    "exercise": "Use for...of to iterate over a Map of country codes and log each pair.",
    "hint": "for (const [code, name] of map) { ... }",
    "solution": "const countries = new Map([[\"US\", \"United States\"], [\"JP\", \"Japan\"], [\"DE\", \"Germany\"]]);\nfor (const [code, name] of countries) {\n  console.log(code + \": \" + name);\n}"
  },
  {
    "es": "ES2015",
    "id": "generators",
    "title": "Generators",
    "description": "Functions that can pause and resume, yielding values one at a time.\n[Spec](https://262.ecma-international.org/6.0/#sec-generator-function-definitions)",
    "starterCode": "// Problem: creating custom sequences lazily (without building full arrays)\n\n// ES2015: generator functions yield values on demand\nfunction* range(start, end) {\n  for (let i = start; i <= end; i++) yield i;\n}\n\n// Consume lazily with for...of\nfor (const n of range(1, 5)) console.log(\"n:\", n);\n\n// Convert to array when needed\nconsole.log(\"array:\", [...range(10, 15)]);\n\n// Infinite sequences (take only what you need)\nfunction* fibonacci() {\n  let a = 0, b = 1;\n  while (true) { yield a; [a, b] = [b, a + b]; }\n}\nconst first10 = [];\nfor (const n of fibonacci()) {\n  first10.push(n);\n  if (first10.length >= 10) break;\n}\nconsole.log(\"fib:\", first10);",
    "exercise": "Create a generator that yields powers of 2: 1, 2, 4, 8, 16... Take the first 6.",
    "hint": "function* powers() { let n = 1; while(true) { yield n; n *= 2; } }",
    "solution": "function* powers() {\n  let n = 1;\n  while (true) { yield n; n *= 2; }\n}\nconst result = [];\nfor (const p of powers()) {\n  result.push(p);\n  if (result.length >= 6) break;\n}\nconsole.log(result);"
  },
  {
    "es": "ES2015",
    "id": "modules",
    "title": "Modules (import/export)",
    "description": "Native module system with import/export. Each file is its own scope.\n[Spec](https://262.ecma-international.org/6.0/#sec-modules)",
    "starterCode": "// Problem: no native module system (relied on globals, CommonJS, AMD)\n\n// ES2015: import/export (shown as comments since this is a single file)\n\n// Named exports (in math.js):\n// export const PI = 3.14;\n// export function add(a, b) { return a + b; }\n\n// Named imports:\n// import { PI, add } from \"./math.js\";\n\n// Default export (in logger.js):\n// export default function log(msg) { console.log(msg); }\n\n// Default import:\n// import log from \"./logger.js\";\n\n// Rename on import:\n// import { add as sum } from \"./math.js\";\n\n// Simulating the concept:\nconst mathModule = { PI: 3.14, add: (a, b) => a + b };\nconst { PI, add } = mathModule;\nconsole.log(\"PI:\", PI);\nconsole.log(\"add:\", add(2, 3));",
    "exercise": "Write an export statement for a function called greet and an import statement to use it.",
    "hint": "export function greet() {} / import { greet } from \"./mod.js\"",
    "solution": "// In greet.js:\n// export function greet(name) { return \"Hello, \" + name; }\n\n// In main.js:\n// import { greet } from \"./greet.js\";\n// console.log(greet(\"World\"));\n\n// Simulated:\nconst greet = name => \"Hello, \" + name;\nconsole.log(greet(\"World\"));"
  },
  {
    "es": "ES2015",
    "id": "promises",
    "title": "Promises",
    "description": "A standard way to handle async operations. Replaces callback hell with chainable .then().\n[Spec](https://262.ecma-international.org/6.0/#sec-promise-objects)",
    "starterCode": "// Problem: nested callbacks (\"callback hell\")\n// Before:\n// getUser(id, function(user) {\n//   getPosts(user, function(posts) {\n//     getComments(posts[0], function(comments) {\n//       console.log(comments); // deeply nested!\n//     });\n//   });\n// });\n\n// ES2015: Promises chain instead of nest\nfunction fetchUser(id) {\n  return new Promise((resolve, reject) => {\n    setTimeout(() => resolve({ id, name: \"Alice\" }), 50);\n  });\n}\n\nfetchUser(1)\n  .then(user => {\n    console.log(\"got:\", user.name);\n    return user.name.toUpperCase();\n  })\n  .then(upper => console.log(\"upper:\", upper))\n  .catch(err => console.log(\"error:\", err.message));",
    "exercise": "Create a Promise that resolves with \"done\" after 100ms. Chain .then() to log the result.",
    "hint": "new Promise(resolve => setTimeout(() => resolve(\"done\"), 100))",
    "solution": "const p = new Promise(resolve => setTimeout(() => resolve(\"done\"), 100));\np.then(val => console.log(\"result:\", val));"
  },
  {
    "es": "ES2015",
    "id": "map-collection",
    "title": "Map",
    "description": "A key-value collection where keys can be any type (not just strings like plain objects).\n[Spec](https://262.ecma-international.org/6.0/#sec-map-objects)",
    "starterCode": "// Problem: plain objects only allow string/symbol keys\nconst objMap = {};\nobjMap[1] = \"one\";\nobjMap[\"1\"] = \"string one\";\nconsole.log(\"object keys collide:\", Object.keys(objMap)); // [\"1\"]\n\n// ES2015: Map preserves key types\nconst m = new Map();\nm.set(1, \"number one\");\nm.set(\"1\", \"string one\");\nconsole.log(\"map size:\", m.size); // 2 (no collision!)\nconsole.log(\"get 1:\", m.get(1));\nconsole.log(\"get '1':\", m.get(\"1\"));\n\n// Objects as keys\nconst user = { name: \"Alice\" };\nm.set(user, \"admin\");\nconsole.log(\"obj key:\", m.get(user));\n\n// Iterate with for...of\nfor (const [k, v] of m) console.log(k, \"=>\", v);",
    "exercise": "Create a Map that maps DOM-like objects to their roles. Retrieve a value by object reference.",
    "hint": "const m = new Map(); m.set(obj, \"value\")",
    "solution": "const header = { tag: \"h1\" };\nconst nav = { tag: \"nav\" };\nconst roles = new Map();\nroles.set(header, \"banner\");\nroles.set(nav, \"navigation\");\nconsole.log(\"header:\", roles.get(header));"
  },
  {
    "es": "ES2015",
    "id": "set-collection",
    "title": "Set",
    "description": "A collection of unique values. Duplicates are automatically ignored.\n[Spec](https://262.ecma-international.org/6.0/#sec-set-objects)",
    "starterCode": "// Problem: arrays don't enforce uniqueness\nconst arr = [1, 2, 3, 2, 1];\n// Before: filter for unique\nconst unique = arr.filter((v, i, a) => a.indexOf(v) === i);\nconsole.log(\"old:\", unique);\n\n// ES2015: Set automatically deduplicates\nconst s = new Set([1, 2, 3, 2, 1]);\nconsole.log(\"set:\", [...s]);       // [1, 2, 3]\nconsole.log(\"size:\", s.size);\nconsole.log(\"has 2:\", s.has(2));\n\ns.add(4);\ns.delete(1);\nconsole.log(\"after:\", [...s]);\n\n// Quick array dedup\nconst tags = [\"js\", \"css\", \"js\", \"html\", \"css\"];\nconst uniqueTags = [...new Set(tags)];\nconsole.log(\"unique tags:\", uniqueTags);",
    "exercise": "Remove duplicates from [\"a\",\"b\",\"a\",\"c\",\"b\",\"d\"] using a Set.",
    "hint": "[...new Set(arr)]",
    "solution": "const arr = [\"a\", \"b\", \"a\", \"c\", \"b\", \"d\"];\nconst unique = [...new Set(arr)];\nconsole.log(unique);"
  },
  {
    "es": "ES2015",
    "id": "weakmap",
    "title": "WeakMap",
    "description": "A Map where keys must be objects and entries are garbage-collected when the key is no longer referenced.\n[Spec](https://262.ecma-international.org/6.0/#sec-weakmap-objects)",
    "starterCode": "// Problem: storing metadata about objects can cause memory leaks\n// A regular Map holds strong references, preventing garbage collection\n\n// ES2015: WeakMap holds weak references\nconst cache = new WeakMap();\n\nlet user = { name: \"Alice\" };\ncache.set(user, { lastLogin: Date.now() });\nconsole.log(\"cached:\", cache.get(user));\n\n// When user is set to null, the WeakMap entry can be garbage collected\n// user = null; // entry becomes eligible for GC\n\n// Common use: private data per object\nconst privateData = new WeakMap();\nclass Person {\n  constructor(name, age) {\n    privateData.set(this, { age });\n    this.name = name;\n  }\n  getAge() { return privateData.get(this).age; }\n}\nconst p = new Person(\"Bob\", 25);\nconsole.log(p.name, \"age:\", p.getAge());",
    "exercise": "Use a WeakMap to store visit counts for objects without leaking memory.",
    "hint": "const visits = new WeakMap(); visits.set(obj, count)",
    "solution": "const visits = new WeakMap();\nconst page = { url: \"/home\" };\nvisits.set(page, 0);\nvisits.set(page, visits.get(page) + 1);\nconsole.log(\"visits:\", visits.get(page));"
  },
  {
    "es": "ES2015",
    "id": "weakset",
    "title": "WeakSet",
    "description": "A Set of objects with weak references. Useful for tagging objects without preventing garbage collection.\n[Spec](https://262.ecma-international.org/6.0/#sec-weakset-objects)",
    "starterCode": "// Problem: tracking which objects have been \"seen\" without leaking memory\n\n// ES2015: WeakSet for object tagging\nconst processed = new WeakSet();\n\nfunction processOnce(item) {\n  if (processed.has(item)) {\n    console.log(\"already processed:\", item.id);\n    return;\n  }\n  processed.add(item);\n  console.log(\"processing:\", item.id);\n}\n\nconst a = { id: 1 };\nconst b = { id: 2 };\nprocessOnce(a); // processing: 1\nprocessOnce(b); // processing: 2\nprocessOnce(a); // already processed: 1",
    "exercise": "Use a WeakSet to track which users have been greeted, so each is only greeted once.",
    "hint": "if (!greeted.has(user)) { greeted.add(user); ... }",
    "solution": "const greeted = new WeakSet();\nfunction greet(user) {\n  if (greeted.has(user)) return console.log(user.name, \"already greeted\");\n  greeted.add(user);\n  console.log(\"Hello,\", user.name + \"!\");\n}\nconst u = { name: \"Alice\" };\ngreet(u); greet(u);"
  },
  {
    "es": "ES2015",
    "id": "symbols",
    "title": "Symbols",
    "description": "Unique, immutable identifiers. Two Symbols are never equal, even with the same description.\n[Spec](https://262.ecma-international.org/6.0/#sec-symbol-objects)",
    "starterCode": "// Problem: string keys can collide\nconst obj = {};\nobj.id = \"user-id\";\nobj.id = \"overwritten!\"; // collision!\n\n// ES2015: Symbols are always unique\nconst id1 = Symbol(\"id\");\nconst id2 = Symbol(\"id\");\nconsole.log(\"equal?\", id1 === id2); // false! Always unique\n\nconst user = {};\nuser[id1] = \"secret-123\";\nuser[id2] = \"secret-456\";\nconsole.log(\"id1:\", user[id1]);\nconsole.log(\"id2:\", user[id2]);\n\n// Symbols don't show up in for...in or Object.keys\nuser.name = \"Alice\";\nconsole.log(\"keys:\", Object.keys(user)); // [\"name\"] only\nconsole.log(\"symbols:\", Object.getOwnPropertySymbols(user));",
    "exercise": "Create a Symbol-keyed \"hidden\" property on an object. Show it does not appear in Object.keys.",
    "hint": "const secret = Symbol(\"secret\"); obj[secret] = value",
    "solution": "const secret = Symbol(\"hidden\");\nconst obj = { visible: true };\nobj[secret] = \"can't see me\";\nconsole.log(\"keys:\", Object.keys(obj));\nconsole.log(\"secret:\", obj[secret]);"
  },
  {
    "es": "ES2015",
    "id": "proxy",
    "title": "Proxy",
    "description": "Intercept and customize object operations like property access, assignment, and function calls.\n[Spec](https://262.ecma-international.org/6.0/#sec-proxy-objects)",
    "starterCode": "// Problem: no way to react to property access or validate assignments\n\n// ES2015: Proxy wraps an object with custom behavior\nconst user = { name: \"Alice\", age: 30 };\n\nconst guarded = new Proxy(user, {\n  get(target, prop) {\n    if (prop in target) return target[prop];\n    return \"property '\" + prop + \"' not found\";\n  },\n  set(target, prop, value) {\n    if (prop === \"age\" && typeof value !== \"number\") {\n      throw new TypeError(\"age must be a number\");\n    }\n    target[prop] = value;\n    return true;\n  }\n});\n\nconsole.log(\"name:\", guarded.name);\nconsole.log(\"missing:\", guarded.email);\nguarded.age = 31;\nconsole.log(\"age:\", guarded.age);",
    "exercise": "Create a Proxy that logs every property access.",
    "hint": "get(target, prop) { console.log(\"accessed:\", prop); return target[prop]; }",
    "solution": "const data = { x: 1, y: 2 };\nconst logged = new Proxy(data, {\n  get(target, prop) {\n    console.log(\"read:\", prop);\n    return target[prop];\n  }\n});\nlogged.x; logged.y;"
  },
  {
    "es": "ES2015",
    "id": "reflect-api",
    "title": "Reflect",
    "description": "A standard set of methods for object operations. The proper way to implement Proxy traps.\n[Spec](https://262.ecma-international.org/6.0/#sec-reflect-object)",
    "starterCode": "// Problem: object operations use inconsistent syntax\nconst obj = { x: 1 };\n\n// Before: mixed syntax for similar operations\nconsole.log(\"has:\", \"x\" in obj);\ndelete obj.x;\n\n// ES2015: Reflect provides consistent, functional methods\nconst obj2 = { x: 1, y: 2 };\nconsole.log(\"has:\", Reflect.has(obj2, \"x\"));\nconsole.log(\"get:\", Reflect.get(obj2, \"y\"));\nReflect.set(obj2, \"z\", 3);\nconsole.log(\"set:\", obj2.z);\nReflect.deleteProperty(obj2, \"x\");\nconsole.log(\"keys:\", Reflect.ownKeys(obj2));",
    "exercise": "Use Reflect.ownKeys to list all keys of an object, including Symbol keys.",
    "hint": "Reflect.ownKeys(obj) returns strings AND symbols",
    "solution": "const sym = Symbol(\"id\");\nconst obj = { name: \"test\", [sym]: 42 };\nconsole.log(\"all keys:\", Reflect.ownKeys(obj));"
  },
  {
    "es": "ES2015",
    "id": "tagged-tpl-raw",
    "title": "Tagged Templates & String.raw",
    "description": "Tag functions process template literal parts. String.raw preserves backslashes.\n[Spec](https://262.ecma-international.org/6.0/#sec-tagged-templates)",
    "starterCode": "// Problem: sometimes you want to process template strings custom ways\n\n// String.raw: no escape processing\nconsole.log(\"normal:\", \"line1\\nline2\");     // interprets \\n\nconsole.log(\"raw:\", String.raw`line1\\nline2`); // keeps \\n literal\n\n// Tagged templates: function receives parts separately\nfunction highlight(strings, ...values) {\n  return strings.reduce((result, str, i) => {\n    return result + str + (values[i] !== undefined ? \"**\" + values[i] + \"**\" : \"\");\n  }, \"\");\n}\n\nconst name = \"Alice\";\nconst role = \"admin\";\nconsole.log(highlight`User ${name} has role ${role}`);",
    "exercise": "Write a tag function that uppercases all interpolated values.",
    "hint": "function upper(strings, ...vals) { ... }",
    "solution": "function upper(strings, ...vals) {\n  return strings.reduce((r, s, i) =>\n    r + s + (vals[i] !== undefined ? String(vals[i]).toUpperCase() : \"\"), \"\");\n}\nconst item = \"coffee\";\nconst price = 4.5;\nconsole.log(upper`Order: ${item} for $${price}`);"
  },
  {
    "es": "ES2015",
    "id": "array-find",
    "title": "Array find & New Array Methods",
    "description": "Find the first array element matching a condition. Returns the element, not its index.\n[Spec](https://262.ecma-international.org/6.0/#sec-array.prototype.find)",
    "starterCode": "// Problem: finding an item by condition\nconst users = [\n  { name: \"Alice\", age: 30 },\n  { name: \"Bob\", age: 17 },\n  { name: \"Charlie\", age: 25 },\n];\n\n// Before: filter returns an array (wasteful for one item)\nconst old = users.filter(u => u.age > 18)[0];\nconsole.log(\"old:\", old);\n\n// ES2015: find returns the first match (stops early)\nconst found = users.find(u => u.age > 18);\nconsole.log(\"find:\", found);\n\nconst idx = users.findIndex(u => u.name === \"Bob\");\nconsole.log(\"findIndex:\", idx);",
    "exercise": "Find the first number greater than 10 in [3, 7, 12, 5, 20].",
    "hint": "arr.find(n => n > 10)",
    "solution": "const nums = [3, 7, 12, 5, 20];\nconsole.log(\"first > 10:\", nums.find(n => n > 10));\nconsole.log(\"at index:\", nums.findIndex(n => n > 10));"
  },
  {
    "es": "ES2015",
    "id": "unicode-binary-octal",
    "title": "Unicode, Binary & Octal Literals",
    "description": "New number literals for binary (0b) and octal (0o), plus Unicode code point escapes.\n[Spec](https://262.ecma-international.org/6.0/#sec-literals-numeric-literals)",
    "starterCode": "// Problem: binary and octal had no clean literals\n// Before: parseInt workarounds\nconsole.log(\"old binary:\", parseInt(\"1010\", 2));  // 10\nconsole.log(\"old octal:\", parseInt(\"755\", 8));    // 493\n\n// ES2015: binary and octal literals\nconsole.log(\"binary:\", 0b1010);         // 10\nconsole.log(\"octal:\", 0o755);           // 493\nconsole.log(\"permissions:\", 0o644);     // common Unix file permission\n\n// Unicode code point escapes (for emoji and rare chars)\nconsole.log(\"emoji:\", \"\\u{1F600}\");\nconsole.log(\"old way:\", \"\\uD83D\\uDE00\"); // surrogate pair (ugly)\nconsole.log(\"same?\", \"\\u{1F600}\" === \"\\uD83D\\uDE00\");",
    "exercise": "Write the number 255 in binary (0b) and hexadecimal (0x) forms.",
    "hint": "0b11111111 and 0xFF",
    "solution": "console.log(\"binary:\", 0b11111111);\nconsole.log(\"hex:\", 0xFF);\nconsole.log(\"equal?\", 0b11111111 === 0xFF);"
  },
  {
    "es": "ES2015",
    "id": "object-assign",
    "title": "Object.assign & Object.is",
    "description": "Copy properties from one or more objects into a target. Shallow merge.\n[Spec](https://262.ecma-international.org/6.0/#sec-object.assign)",
    "starterCode": "// Problem: merging objects required manual loops\nconst defaults = { color: \"blue\", size: \"md\" };\nconst custom = { size: \"lg\", weight: \"bold\" };\n\n// Before:\nconst old = {};\nfor (const k in defaults) old[k] = defaults[k];\nfor (const k in custom) old[k] = custom[k];\nconsole.log(\"old:\", old);\n\n// ES2015: Object.assign\nconst merged = Object.assign({}, defaults, custom);\nconsole.log(\"new:\", merged);\n\n// Note: it mutates the first argument\nconst target = { a: 1 };\nObject.assign(target, { b: 2 });\nconsole.log(\"mutated:\", target); // { a: 1, b: 2 }\n\n// For immutable merge, use {} as first arg (or spread in ES2018+)",
    "exercise": "Merge {x:1} and {y:2} into a new object without mutating either.",
    "hint": "Object.assign({}, a, b)",
    "solution": "const a = { x: 1 };\nconst b = { y: 2 };\nconst merged = Object.assign({}, a, b);\nconsole.log(merged);\nconsole.log(\"a unchanged:\", a);"
  },
  {
    "es": "ES2015",
    "id": "tail-calls",
    "title": "Tail Call Optimization",
    "description": "Proper tail calls prevent stack overflow in recursive functions (spec feature, limited engine support).\n[Spec](https://262.ecma-international.org/6.0/#sec-tail-position-calls)",
    "starterCode": "// Problem: deep recursion causes stack overflow\n// Before: regular recursion builds up the call stack\nfunction factorialOld(n) {\n  if (n <= 1) return 1;\n  return n * factorialOld(n - 1); // NOT a tail call (multiply after)\n}\nconsole.log(\"old:\", factorialOld(10));\n\n// ES2015: tail calls reuse the stack frame\n// A tail call is when the return value IS the function call (nothing after)\nfunction factorial(n, acc = 1) {\n  if (n <= 1) return acc;\n  return factorial(n - 1, n * acc); // tail call: nothing after the call\n}\nconsole.log(\"new:\", factorial(10));\n\n// Note: only Safari fully implements this optimization\n// Other engines may still overflow on deep recursion",
    "exercise": "Rewrite a sum function as tail-recursive: sum(n) = 1 + 2 + ... + n.",
    "hint": "function sum(n, acc = 0) { return n <= 0 ? acc : sum(n-1, acc+n); }",
    "solution": "function sum(n, acc = 0) {\n  if (n <= 0) return acc;\n  return sum(n - 1, acc + n);\n}\nconsole.log(\"sum 1-100:\", sum(100));"
  }
]