added base examples and drills
This commit is contained in:
@@ -1,3 +1,43 @@
|
|||||||
# refactor-kata
|
# refactor-kata
|
||||||
|
|
||||||
These are various practice drills for practicing refactoring. Using "Working With Legacy Code" by Feathers and "Refactoring" by Fowler
|
Practice drills for refactoring, especially with ideas from *Working Effectively with Legacy Code* by Michael Feathers and *Refactoring* by Martin Fowler.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `kata/<example-name>/original/`
|
||||||
|
- the frozen messy starting point
|
||||||
|
- copy from here when creating a new drill scenario
|
||||||
|
- `kata/<example-name>/scenarios/<scenario-name>/code/`
|
||||||
|
- the working copy for a specific drill attempt
|
||||||
|
- `kata/<example-name>/scenarios/<scenario-name>/drill-prompt.md`
|
||||||
|
- instructions and success conditions for that drill
|
||||||
|
- `kata/<example-name>/scenarios/<scenario-name>/notes.md`
|
||||||
|
- notes on seams found, tests added, techniques used, and lessons learned
|
||||||
|
- `notes/`
|
||||||
|
- cross-kata notes and recurring patterns
|
||||||
|
|
||||||
|
## Intended workflow
|
||||||
|
|
||||||
|
1. Start with code in `original/`.
|
||||||
|
2. Copy that code into a scenario's `code/` folder.
|
||||||
|
3. Do the refactor only inside that scenario copy.
|
||||||
|
4. Record what you observed and learned in `notes.md`.
|
||||||
|
5. Reset by making a fresh scenario from `original/`.
|
||||||
|
|
||||||
|
## Current starter kata
|
||||||
|
|
||||||
|
- `kata/example-seam-drill/`
|
||||||
|
- a placeholder kata for practicing:
|
||||||
|
- characterization tests
|
||||||
|
- breaking one dependency with a seam
|
||||||
|
- extracting one pure decision from mixed logic
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The point is not to build a production app. The point is to practice:
|
||||||
|
|
||||||
|
- finding seams in messy code
|
||||||
|
- adding characterization tests safely
|
||||||
|
- making small reviewable refactors
|
||||||
|
- separating decisions from mechanics
|
||||||
|
- gradually moving code toward stronger boundaries
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# auth original
|
||||||
|
|
||||||
|
Messy login logic mixing lockout policy, clock handling, token generation, mutation, logging, and file I/O.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function login(filePath, username, password) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const user = state.users.find((item) => item.username === username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.lockedUntil && new Date(user.lockedUntil).getTime() > Date.now()) {
|
||||||
|
return { ok: false, reason: "locked" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.password !== password) {
|
||||||
|
user.failedAttempts = user.failedAttempts + 1;
|
||||||
|
|
||||||
|
if (user.failedAttempts >= 3) {
|
||||||
|
user.lockedUntil = new Date(Date.now() + 15 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||||
|
return { ok: false, reason: "invalid-password" };
|
||||||
|
}
|
||||||
|
|
||||||
|
user.failedAttempts = 0;
|
||||||
|
user.lockedUntil = null;
|
||||||
|
user.lastLoginAt = new Date().toISOString();
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||||
|
console.log("Logged in", username);
|
||||||
|
|
||||||
|
return { ok: true, token: "token-" + username + "-" + Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { login };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "ada",
|
||||||
|
"password": "secret",
|
||||||
|
"failedAttempts": 0,
|
||||||
|
"lockedUntil": null,
|
||||||
|
"lastLoginAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# billing original
|
||||||
|
|
||||||
|
Messy invoice finalization logic mixing pricing rules, time, logging, mutation, and file I/O.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function finalizeInvoice(filePath, invoiceId, customerType) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const invoice = state.invoices.find((item) => item.id === invoiceId);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
throw new Error("Invoice not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let subtotal = 0;
|
||||||
|
|
||||||
|
for (const line of invoice.lines) {
|
||||||
|
subtotal = subtotal + line.quantity * line.unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
let discount = 0;
|
||||||
|
|
||||||
|
if (customerType === "vip") {
|
||||||
|
discount = subtotal * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtotal > 200) {
|
||||||
|
discount = discount + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxed = (subtotal - discount) * 1.1;
|
||||||
|
invoice.status = "finalized";
|
||||||
|
invoice.total = Math.round(taxed * 100) / 100;
|
||||||
|
invoice.finalizedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||||
|
console.log("Invoice finalized", invoiceId);
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { finalizeInvoice };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"invoices": [
|
||||||
|
{
|
||||||
|
"id": "inv-1",
|
||||||
|
"status": "draft",
|
||||||
|
"total": 0,
|
||||||
|
"finalizedAt": null,
|
||||||
|
"lines": [
|
||||||
|
{ "description": "Hosting", "quantity": 2, "unitPrice": 60 },
|
||||||
|
{ "description": "Support", "quantity": 1, "unitPrice": 95 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# example-seam-drill
|
||||||
|
|
||||||
|
This kata is a placeholder example showing the intended practice layout.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `original/`: the frozen messy source you copy from for each drill
|
||||||
|
- `scenarios/<scenario-name>/code/`: a working copy for one drill attempt
|
||||||
|
- `scenarios/<scenario-name>/drill-prompt.md`: the drill instructions
|
||||||
|
- `scenarios/<scenario-name>/notes.md`: what you observed, tried, and learned
|
||||||
|
|
||||||
|
## Suggested workflow
|
||||||
|
|
||||||
|
1. Put the messy starting code in `original/`.
|
||||||
|
2. Copy it into a scenario's `code/` directory.
|
||||||
|
3. Do the drill only inside that scenario copy.
|
||||||
|
4. Record seam choices, tests added, and refactor steps in `notes.md`.
|
||||||
|
5. Reset by creating a new scenario from `original/`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# original
|
||||||
|
|
||||||
|
This is intentionally messy starter code.
|
||||||
|
|
||||||
|
## Why it is messy
|
||||||
|
|
||||||
|
- mixes file I/O, business rules, time, and logging in one method
|
||||||
|
- uses raw strings and generic objects instead of stronger primitives
|
||||||
|
- hard-codes reward logic inline
|
||||||
|
- depends directly on `new Date()` and `fs`
|
||||||
|
- mutates loaded state directly
|
||||||
|
|
||||||
|
## Suggested drill targets
|
||||||
|
|
||||||
|
- characterize current completion behavior
|
||||||
|
- break the file-system or clock dependency with a seam
|
||||||
|
- extract the points or badge decision into a pure function
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "example-seam-drill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
class TaskService {
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTask(taskName, userName) {
|
||||||
|
const raw = fs.readFileSync(this.filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const task = state.tasks.find((item) => item.name === taskName);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error("Task not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed) {
|
||||||
|
return {
|
||||||
|
message: "Task already complete",
|
||||||
|
pointsAwarded: 0,
|
||||||
|
badge: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
task.completed = true;
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
let pointsAwarded = 10;
|
||||||
|
|
||||||
|
if (userName && userName.toLowerCase() === "ada") {
|
||||||
|
pointsAwarded = pointsAwarded + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date().getDay() === 5) {
|
||||||
|
pointsAwarded = pointsAwarded * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.points = state.points + pointsAwarded;
|
||||||
|
|
||||||
|
let badge = null;
|
||||||
|
|
||||||
|
if (state.points > 50) {
|
||||||
|
badge = "consistency-star";
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
console.log("Awarded badge:", badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Task completed",
|
||||||
|
pointsAwarded,
|
||||||
|
badge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TaskService
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"points": 45,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Write daily summary",
|
||||||
|
"completed": false,
|
||||||
|
"completedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Review inbox",
|
||||||
|
"completed": true,
|
||||||
|
"completedAt": "2026-05-18T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# original
|
||||||
|
|
||||||
|
This is intentionally messy starter code.
|
||||||
|
|
||||||
|
## Why it is messy
|
||||||
|
|
||||||
|
- mixes file I/O, business rules, time, and logging in one method
|
||||||
|
- uses raw strings and generic objects instead of stronger primitives
|
||||||
|
- hard-codes reward logic inline
|
||||||
|
- depends directly on `new Date()` and `fs`
|
||||||
|
- mutates loaded state directly
|
||||||
|
|
||||||
|
## Suggested drill targets
|
||||||
|
|
||||||
|
- characterize current completion behavior
|
||||||
|
- break the file-system or clock dependency with a seam
|
||||||
|
- extract the points or badge decision into a pure function
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "example-seam-drill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
class TaskService {
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTask(taskName, userName) {
|
||||||
|
const raw = fs.readFileSync(this.filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const task = state.tasks.find((item) => item.name === taskName);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error("Task not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed) {
|
||||||
|
return {
|
||||||
|
message: "Task already complete",
|
||||||
|
pointsAwarded: 0,
|
||||||
|
badge: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
task.completed = true;
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
let pointsAwarded = 10;
|
||||||
|
|
||||||
|
if (userName && userName.toLowerCase() === "ada") {
|
||||||
|
pointsAwarded = pointsAwarded + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date().getDay() === 5) {
|
||||||
|
pointsAwarded = pointsAwarded * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.points = state.points + pointsAwarded;
|
||||||
|
|
||||||
|
let badge = null;
|
||||||
|
|
||||||
|
if (state.points > 50) {
|
||||||
|
badge = "consistency-star";
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
console.log("Awarded badge:", badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Task completed",
|
||||||
|
pointsAwarded,
|
||||||
|
badge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TaskService
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"points": 45,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Write daily summary",
|
||||||
|
"completed": false,
|
||||||
|
"completedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Review inbox",
|
||||||
|
"completed": true,
|
||||||
|
"completedAt": "2026-05-18T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Drill: break-dependency
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Introduce one seam that reduces coupling to a hard dependency.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
Find one hard dependency in `code/` such as time, randomness, persistence, network, UI, or global state. Introduce the smallest seam you can so that behavior can be controlled more easily in tests.
|
||||||
|
|
||||||
|
## Success conditions
|
||||||
|
- One dependency is isolated behind a seam
|
||||||
|
- The change is small and reviewable
|
||||||
|
- You can explain the enabling point and why it is safer to change now
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
## Dependency chosen
|
||||||
|
|
||||||
|
## Seam introduced
|
||||||
|
|
||||||
|
## Why this seam
|
||||||
|
|
||||||
|
## What I learned
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# original
|
||||||
|
|
||||||
|
This is intentionally messy starter code.
|
||||||
|
|
||||||
|
## Why it is messy
|
||||||
|
|
||||||
|
- mixes file I/O, business rules, time, and logging in one method
|
||||||
|
- uses raw strings and generic objects instead of stronger primitives
|
||||||
|
- hard-codes reward logic inline
|
||||||
|
- depends directly on `new Date()` and `fs`
|
||||||
|
- mutates loaded state directly
|
||||||
|
|
||||||
|
## Suggested drill targets
|
||||||
|
|
||||||
|
- characterize current completion behavior
|
||||||
|
- break the file-system or clock dependency with a seam
|
||||||
|
- extract the points or badge decision into a pure function
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "example-seam-drill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
class TaskService {
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTask(taskName, userName) {
|
||||||
|
const raw = fs.readFileSync(this.filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const task = state.tasks.find((item) => item.name === taskName);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error("Task not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed) {
|
||||||
|
return {
|
||||||
|
message: "Task already complete",
|
||||||
|
pointsAwarded: 0,
|
||||||
|
badge: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
task.completed = true;
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
let pointsAwarded = 10;
|
||||||
|
|
||||||
|
if (userName && userName.toLowerCase() === "ada") {
|
||||||
|
pointsAwarded = pointsAwarded + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date().getDay() === 5) {
|
||||||
|
pointsAwarded = pointsAwarded * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.points = state.points + pointsAwarded;
|
||||||
|
|
||||||
|
let badge = null;
|
||||||
|
|
||||||
|
if (state.points > 50) {
|
||||||
|
badge = "consistency-star";
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
console.log("Awarded badge:", badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Task completed",
|
||||||
|
pointsAwarded,
|
||||||
|
badge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TaskService
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"points": 45,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Write daily summary",
|
||||||
|
"completed": false,
|
||||||
|
"completedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Review inbox",
|
||||||
|
"completed": true,
|
||||||
|
"completedAt": "2026-05-18T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Drill: characterization
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Capture current behavior before changing the code.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
Add characterization tests for the current behavior of the code in `code/` without intentionally changing behavior.
|
||||||
|
|
||||||
|
## Success conditions
|
||||||
|
- At least one important behavior is captured in tests
|
||||||
|
- You can describe what the code currently does, even if the behavior is awkward
|
||||||
|
- No production behavior changes are introduced on purpose
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
## Observed behavior
|
||||||
|
|
||||||
|
## Seams discovered
|
||||||
|
|
||||||
|
## Tests added
|
||||||
|
|
||||||
|
## What I learned
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# original
|
||||||
|
|
||||||
|
This is intentionally messy starter code.
|
||||||
|
|
||||||
|
## Why it is messy
|
||||||
|
|
||||||
|
- mixes file I/O, business rules, time, and logging in one method
|
||||||
|
- uses raw strings and generic objects instead of stronger primitives
|
||||||
|
- hard-codes reward logic inline
|
||||||
|
- depends directly on `new Date()` and `fs`
|
||||||
|
- mutates loaded state directly
|
||||||
|
|
||||||
|
## Suggested drill targets
|
||||||
|
|
||||||
|
- characterize current completion behavior
|
||||||
|
- break the file-system or clock dependency with a seam
|
||||||
|
- extract the points or badge decision into a pure function
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "example-seam-drill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
class TaskService {
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTask(taskName, userName) {
|
||||||
|
const raw = fs.readFileSync(this.filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const task = state.tasks.find((item) => item.name === taskName);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error("Task not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.completed) {
|
||||||
|
return {
|
||||||
|
message: "Task already complete",
|
||||||
|
pointsAwarded: 0,
|
||||||
|
badge: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
task.completed = true;
|
||||||
|
task.completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
let pointsAwarded = 10;
|
||||||
|
|
||||||
|
if (userName && userName.toLowerCase() === "ada") {
|
||||||
|
pointsAwarded = pointsAwarded + 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date().getDay() === 5) {
|
||||||
|
pointsAwarded = pointsAwarded * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.points = state.points + pointsAwarded;
|
||||||
|
|
||||||
|
let badge = null;
|
||||||
|
|
||||||
|
if (state.points > 50) {
|
||||||
|
badge = "consistency-star";
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
console.log("Awarded badge:", badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Task completed",
|
||||||
|
pointsAwarded,
|
||||||
|
badge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TaskService
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"points": 45,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Write daily summary",
|
||||||
|
"completed": false,
|
||||||
|
"completedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Review inbox",
|
||||||
|
"completed": true,
|
||||||
|
"completedAt": "2026-05-18T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Drill: extract-policy
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Move one mixed rule out of orchestration code into a pure decision.
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
Find logic in `code/` that mixes decision-making with mechanics. Extract the decision into a pure function or policy with explicit inputs and outputs.
|
||||||
|
|
||||||
|
## Success conditions
|
||||||
|
- The decision is separated from mechanics
|
||||||
|
- Inputs and outputs are explicit
|
||||||
|
- You can describe the command, event, and workflow role of the affected code
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
## Mixed logic found
|
||||||
|
|
||||||
|
## Pure decision extracted
|
||||||
|
|
||||||
|
## Command / event / workflow mapping
|
||||||
|
|
||||||
|
## What I learned
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# moderation original
|
||||||
|
|
||||||
|
Messy moderation logic mixing policy rules, time, logging, mutation, and file I/O.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function reviewPost(filePath, postId) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const post = state.posts.find((item) => item.id === postId);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
throw new Error("Post not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = "approve";
|
||||||
|
|
||||||
|
if (post.text.includes("buy now") || post.text.includes("free money")) {
|
||||||
|
action = "reject";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.reports > 3) {
|
||||||
|
action = "escalate";
|
||||||
|
}
|
||||||
|
|
||||||
|
post.status = action;
|
||||||
|
post.reviewedAt = new Date().toISOString();
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||||
|
console.log("Moderation action", postId, action);
|
||||||
|
|
||||||
|
return { action };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { reviewPost };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": "post-1",
|
||||||
|
"text": "free money if you buy now",
|
||||||
|
"reports": 1,
|
||||||
|
"status": "pending",
|
||||||
|
"reviewedAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# notifications original
|
||||||
|
|
||||||
|
Messy reminder logic mixing quiet-hours rules, channel checks, time, logging, mutation, and file I/O.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function sendReminder(filePath, userId, channel) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const user = state.users.find((item) => item.id === userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.remindersEnabled) {
|
||||||
|
return { sent: false, reason: "disabled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
|
||||||
|
if (hour < 8 || hour > 20) {
|
||||||
|
return { sent: false, reason: "quiet-hours" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === "sms" && !user.phoneNumber) {
|
||||||
|
return { sent: false, reason: "missing-phone" };
|
||||||
|
}
|
||||||
|
|
||||||
|
user.lastReminderAt = new Date().toISOString();
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||||
|
console.log("Sent reminder", userId, channel);
|
||||||
|
|
||||||
|
return { sent: true, channel };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendReminder };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "user-1",
|
||||||
|
"remindersEnabled": true,
|
||||||
|
"phoneNumber": "+15555555555",
|
||||||
|
"lastReminderAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# shipping original
|
||||||
|
|
||||||
|
Messy shipping logic mixing eligibility, cost calculation, randomness, time, logging, and file I/O.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function shipOrder(filePath, orderId, destinationCountry) {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
const order = state.orders.find((item) => item.id === orderId);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new Error("Order not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === "shipped") {
|
||||||
|
return { message: "Already shipped", trackingNumber: order.trackingNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
let shippingCost = 5;
|
||||||
|
|
||||||
|
if (destinationCountry !== "US") {
|
||||||
|
shippingCost = shippingCost + 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.weightKg > 10) {
|
||||||
|
shippingCost = shippingCost + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
order.status = "shipped";
|
||||||
|
order.shippingCost = shippingCost;
|
||||||
|
order.trackingNumber = "trk-" + Math.floor(Math.random() * 100000);
|
||||||
|
order.shippedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||||
|
console.log("Order shipped", orderId);
|
||||||
|
|
||||||
|
return { message: "Shipped", trackingNumber: order.trackingNumber, shippingCost };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { shipOrder };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"id": "ord-1",
|
||||||
|
"status": "paid",
|
||||||
|
"weightKg": 12,
|
||||||
|
"shippingCost": 0,
|
||||||
|
"trackingNumber": null,
|
||||||
|
"shippedAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Practice Notes
|
||||||
|
|
||||||
|
Use this directory for cross-kata notes:
|
||||||
|
|
||||||
|
- patterns you keep noticing
|
||||||
|
- seams that worked well
|
||||||
|
- refactor techniques that felt natural or hard
|
||||||
|
- mapping from legacy code moves to effect-template ideas
|
||||||
Reference in New Issue
Block a user