Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
94d8166
init: instructions
azaidrahman May 5, 2026
97b0d09
new file for planning
ponli550 May 6, 2026
941669d
uncached md files for organized structure
ponli550 May 6, 2026
64dbe72
add priority field to todo items schema and update persistence layers…
ponli550 May 6, 2026
462558c
added priority so output json include priority
ponli550 May 6, 2026
5ea06a7
add const updates to include priority levels based on different id
ponli550 May 6, 2026
10b1f71
adding form select item to be set priority. wrap inside input form fo…
ponli550 May 6, 2026
4346c8b
adding priority within item column and added three colors
ponli550 May 6, 2026
e026e91
reupdate , rerender <prirrityBadge> , debug
ponli550 May 6, 2026
85ec187
added priority so that json output corectly with priority
ponli550 May 6, 2026
e4b3991
wrong word for export function
ponli550 May 6, 2026
7009e2b
Merge pull request #1 from azaidrahman/feat/PriorityLevel
ponli550 May 7, 2026
b7a2942
hi
May 7, 2026
3a06f99
Change filter from created to category instead
May 7, 2026
cb8c952
change sort for category instead of sort
May 7, 2026
b67f564
set priority as default for sort
May 7, 2026
6a38a31
change priority value for sort
May 7, 2026
33a086e
resolve conflicts
May 7, 2026
e47f0c0
gitignored
ponli550 May 7, 2026
9a44288
normalise values for category and priority
May 7, 2026
75ba1e5
feat: add due date support to todo items with persistence, validation…
ponli550 May 7, 2026
38dc325
feat: configure Vite proxy to forward API requests to localhost:3000
ponli550 May 7, 2026
be045c0
bug fix
ponli550 May 7, 2026
54c8b99
Merge pull request #2 from azaidrahman/feat/duedate-version-2
ponli550 May 7, 2026
872b786
add space
May 7, 2026
51f1c92
merge origin into branch
May 7, 2026
5bd39d7
remove comment
May 7, 2026
b14cc26
Merge pull request #3 from azaidrahman/feat/search-sort
smolshen May 7, 2026
ace26d9
add category, category filter and optimize ui for mobile screen
May 7, 2026
5e1e3f1
moved vars into func
niknazmirnadim-lgtm May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,13 @@ dist
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.pnp.*

# Reviewer notes
ANSWERS.md
PRIORITY_FEATURE.md
planIrfan.md
learning.md
planRa2.md

planShane.md
84 changes: 84 additions & 0 deletions INSTRUCTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 1-Week Sprint: Git, Docker, and a Real Codebase

**Repo:** Fork of [`docker/getting-started-todo-app`](https://github.com/docker/getting-started-todo-app)
**Goal:** Ship 4 features through PR review. Then rebuild the Compose stack solo.

---

## Setup

- You're added as contributors. Clone the repo directly.
- `main` is protected: PR required, 1 reviewer, no direct pushes, no force-pushes.
- Verify `docker compose up --watch` works. Flag if not.

---

## Start

- 1: Priority levels (low/med/high). DB column, API field, colored badge.
- 2: Due dates. DB column, validation, date picker, overdue styling.
- 3: Categories (work/personal/shopping). Enum column, filter dropdown.
- 4: Search + sort by created/priority/due. No schema, heavy UI edits.

You will all touch the same migration, API handler, and `TodoList` component. **Don't coordinate to avoid this.** Conflicts are the lesson.

**Review, merge, conflict.** PRs go up. You review someone else's before yours merges. Merge order = ready order. After the first merge, everyone else rebases and resolves their own conflicts. Delete merged branches.

**Rebuild the Compose stack solo.** New branch `compose/<name>`. Rename the existing `Dockerfile` and `compose.yaml` to `Dockerfile.example` and `compose.example.yaml`. Rebuild from scratch.

1-hour walkthrough, we cherry-pick the best ideas into a team `compose.yaml`.

---

## Rules

- No direct commits to `main`. No self-approving PRs.
- AI is encouraged but please try and use git and docker yourself.
- PR description: what changed, how to test, screenshot.
- Commit messages in imperative mood. "Add priority field," not "Added priority field."
- Clear and separated branches
- use feat/, fix/, etc branch conventions (https://conventional-branch.github.io/)

---

## What I'll review on your Compose stack

**Backend Dockerfile.** Multi-stage. Non-root. `npm install` shouldn't re-run on source-only changes. Healthcheck that proves the app is _up_, not just _alive_.

**Frontend Dockerfile.** Build stage produces static assets. Serve stage is tiny. No `node_modules` in the final image.

**compose.yaml.** Frontend, backend, db, phpMyAdmin. Backend doesn't start before the DB is _ready_. DB data survives `docker compose down`. No hardcoded secrets.

**Dev ergonomics.** One command to a working app with hot reload. `.env.example` tells me what I need without leaking yours.

---

## Deliverables (Friday EOD)

1. Merged feature PR.
2. `compose/<name>` branch.

---

## The existing stack is your starting point, not your target

The checked-in `compose.yaml` and `Dockerfile` work, but they fail parts of the rubric above on purpose. Keep them as `.example` files and read them. Your rebuild should beat them on at least:

- Containers run as root.
- Backend has no healthcheck. Compose has no way to know it's actually serving.
- Secrets are inline in `compose.yaml`. There is no `.env.example`.
- One Dockerfile builds both client and backend, and the prod client is baked into the backend image. Split them. The frontend should build to static assets and be served by something that isn't Node.

---

## Hints (not answers)

- `git rebase` and `git merge` solve the same problem differently. Pick one. Know why.
- `git pull --rebase` saves you unnecessary merge commits.
- Layers that change often go _after_ layers that don't.
- `depends_on` alone doesn't wait for a DB to be ready. Look up `condition`.
- `COPY package*.json ./` before `COPY . .`. There's a reason.
- A static site doesn't need a Node runtime to be served. `nginx:alpine` is ~50MB.
- A container running as root is one `USER` line away from not being.
- `mysqladmin ping` proves MySQL is alive. What proves your API is _serving_?
- `env_file:` and `${VAR}` substitution exist for a reason. So does `.env.example`.
18 changes: 17 additions & 1 deletion backend/spec/persistence/sqlite.spec.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
const os = require('os');
const path = require('path');

process.env.SQLITE_DB_LOCATION = path.join(os.tmpdir(), 'test-todo.db');

const db = require('../../src/persistence/sqlite');
const fs = require('fs');
const location = process.env.SQLITE_DB_LOCATION || '/etc/todos/todo.db';
const location = process.env.SQLITE_DB_LOCATION;

const ITEM = {
id: '7aef3d7c-d301-4846-8358-2a91ec9d6be3',
name: 'Test',
completed: false,
priority: 'medium',
};

beforeEach(() => {
Expand Down Expand Up @@ -63,3 +69,13 @@ test('it can get a single item', async () => {
const item = await db.getItem(ITEM.id);
expect(item).toEqual(ITEM);
});

test('it stores and retrieves the priority field', async () => {
await db.init();

const item = { ...ITEM, priority: 'high' };
await db.storeItem(item);

const items = await db.getItems();
expect(items[0].priority).toBe('high');
});
65 changes: 54 additions & 11 deletions backend/spec/routes/addItem.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const db = require('../../src/persistence');
const addItem = require('../../src/routes/addItem');
const ITEM = { id: 12345 };
const { v4: uuid } = require('uuid');

jest.mock('uuid', () => ({ v4: jest.fn() }));
Expand All @@ -11,20 +10,64 @@ jest.mock('../../src/persistence', () => ({
getItem: jest.fn(),
}));

test('it stores item correctly', async () => {
const id = 'something-not-a-uuid';
const name = 'A sample item';
const req = { body: { name } };
const ID = 'something-not-a-uuid';
const NAME = 'A sample item';

beforeEach(() => {
jest.clearAllMocks();
uuid.mockReturnValue(ID);
});

test('it stores item with default priority when none is provided', async () => {
const req = { body: { name: NAME } };
const res = { send: jest.fn() };

await addItem(req, res);

const expectedItem = { id: ID, name: NAME, completed: false, priority: 'medium', due_date: null };
expect(db.storeItem).toHaveBeenCalledWith(expectedItem);
expect(res.send).toHaveBeenCalledWith(expectedItem);
});

test('it stores item with the provided valid priority', async () => {
const req = { body: { name: NAME, priority: 'high' } };
const res = { send: jest.fn() };

await addItem(req, res);

const expectedItem = { id: ID, name: NAME, completed: false, priority: 'high', due_date: null };
expect(db.storeItem).toHaveBeenCalledWith(expectedItem);
expect(res.send).toHaveBeenCalledWith(expectedItem);
});

test('it defaults to medium when an invalid priority is provided', async () => {
const req = { body: { name: NAME, priority: 'critical' } };
const res = { send: jest.fn() };

uuid.mockReturnValue(id);
await addItem(req, res);

expect(db.storeItem).toHaveBeenCalledWith(
expect.objectContaining({ priority: 'medium' }),
);
});

test('it stores item with a valid due_date', async () => {
const req = { body: { name: NAME, due_date: '2026-10-01' } };
const res = { send: jest.fn() };

await addItem(req, res);
expect(db.storeItem).toHaveBeenCalledWith(
expect.objectContaining({ due_date: '2026-10-01' })
);
});

const expectedItem = { id, name, completed: false };
test('it stores null due_date when value is invalid', async () => {
const req = { body: { name: NAME, due_date: 'not-a-date' } };
const res = { send: jest.fn() };

await addItem(req, res);

expect(db.storeItem.mock.calls.length).toBe(1);
expect(db.storeItem.mock.calls[0][0]).toEqual(expectedItem);
expect(res.send.mock.calls[0].length).toBe(1);
expect(res.send.mock.calls[0][0]).toEqual(expectedItem);
expect(db.storeItem).toHaveBeenCalledWith(
expect.objectContaining({ due_date: null }),
);
});
18 changes: 18 additions & 0 deletions backend/spec/routes/updateItem.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ test('it updates items correctly', async () => {
expect(db.updateItem.mock.calls[0][1]).toEqual({
name: 'New title',
completed: false,
priority: 'medium',
due_date: null, // <-- added
});

expect(db.getItem.mock.calls.length).toBe(1);
Expand All @@ -31,3 +33,19 @@ test('it updates items correctly', async () => {
expect(res.send.mock.calls[0].length).toBe(1);
expect(res.send.mock.calls[0][0]).toEqual(ITEM);
});

test('it updates item with a valid due_date', async () => {
const req = {
params: { id: 1234 },
body: { name: 'New title', completed: false, due_date: '2026-06-01' },
};
const res = { send: jest.fn() };

db.getItem.mockReturnValue(Promise.resolve(ITEM));

await updateItem(req, res);

expect(db.updateItem.mock.calls[0][1]).toEqual(
expect.objectContaining({ due_date: '2026-06-01' }),
);
});
8 changes: 8 additions & 0 deletions backend/src/categories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const CATEGORIES = ['work', 'personal', 'shopping'];
const DEFAULT_CATEGORY = 'personal';

function normalizeCategory(category) {
return CATEGORIES.includes(category) ? category : DEFAULT_CATEGORY;
}

module.exports = { CATEGORIES, DEFAULT_CATEGORY, normalizeCategory };
24 changes: 18 additions & 6 deletions backend/src/persistence/mysql.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const waitPort = require('wait-port');
const fs = require('fs');
const mysql = require('mysql2');
const { DEFAULT_CATEGORY, normalizeCategory } = require('../categories');

const {
MYSQL_HOST: HOST,
Expand Down Expand Up @@ -39,12 +40,21 @@ async function init() {

return new Promise((acc, rej) => {
pool.query(
'CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean) DEFAULT CHARSET utf8mb4',
`CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean, priority varchar(10) DEFAULT 'medium', due_date varchar(10) DEFAULT NULL, category VARCHAR(20) NOT NULL DEFAULT '${DEFAULT_CATEGORY}')`,
(err) => {
if (err) return rej(err);

console.log(`Connected to mysql db at host ${HOST}`);
acc();

// Migrate existing databases that predate the category column
pool.query(
`ALTER TABLE todo_items ADD COLUMN category VARCHAR(20) NOT NULL DEFAULT '${DEFAULT_CATEGORY}'`,
(err2) => {
// errno 1060 = duplicate column — already exists, safe to ignore
if (err2 && err2.errno !== 1060) return rej(err2);
acc();
},
);
},
);
});
Expand All @@ -67,6 +77,7 @@ async function getItems() {
rows.map((item) =>
Object.assign({}, item, {
completed: item.completed === 1,
category: normalizeCategory(item.category),
}),
),
);
Expand All @@ -82,6 +93,7 @@ async function getItem(id) {
rows.map((item) =>
Object.assign({}, item, {
completed: item.completed === 1,
category: normalizeCategory(item.category),
}),
)[0],
);
Expand All @@ -92,8 +104,8 @@ async function getItem(id) {
async function storeItem(item) {
return new Promise((acc, rej) => {
pool.query(
'INSERT INTO todo_items (id, name, completed) VALUES (?, ?, ?)',
[item.id, item.name, item.completed ? 1 : 0],
'INSERT INTO todo_items (id, name, completed, priority, due_date, category) VALUES (?, ?, ?, ?, ?, ?)',
[item.id, item.name, item.completed ? 1 : 0, item.priority, item.due_date || null, normalizeCategory(item.category)],
(err) => {
if (err) return rej(err);
acc();
Expand All @@ -105,8 +117,8 @@ async function storeItem(item) {
async function updateItem(id, item) {
return new Promise((acc, rej) => {
pool.query(
'UPDATE todo_items SET name=?, completed=? WHERE id=?',
[item.name, item.completed ? 1 : 0, id],
'UPDATE todo_items SET name=?, completed=?, priority=?, due_date=?, category=? WHERE id=?',
[item.name, item.completed ? 1 : 0, item.priority, item.due_date || null, normalizeCategory(item.category), id],
(err) => {
if (err) return rej(err);
acc();
Expand Down
32 changes: 25 additions & 7 deletions backend/src/persistence/sqlite.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const sqlite3 = require('sqlite3').verbose();
const fs = require('fs');
const { DEFAULT_CATEGORY, normalizeCategory } = require('../categories');
const location = process.env.SQLITE_DB_LOCATION || '/etc/todos/todo.db';

let db, dbAll, dbRun;
Expand All @@ -18,10 +19,25 @@ function init() {
console.log(`Using sqlite database at ${location}`);

db.run(
'CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean)',
(err, result) => {
`CREATE TABLE IF NOT EXISTS todo_items (id varchar(36), name varchar(255), completed boolean, priority varchar(10), due_date varchar(10), category text NOT NULL DEFAULT '${DEFAULT_CATEGORY}')`,
(err) => {
if (err) return rej(err);
acc();

// Migrate existing databases that predate the category column
db.all('PRAGMA table_info(todo_items)', (pragmaErr, rows) => {
if (pragmaErr) return rej(pragmaErr);

const hasCategory = rows.some(col => col.name === 'category');
if (hasCategory) return acc();

db.run(
`ALTER TABLE todo_items ADD COLUMN category text NOT NULL DEFAULT '${DEFAULT_CATEGORY}'`,
(alterErr) => {
if (alterErr) return rej(alterErr);
acc();
},
);
});
},
);
});
Expand All @@ -45,6 +61,7 @@ async function getItems() {
rows.map((item) =>
Object.assign({}, item, {
completed: item.completed === 1,
category: normalizeCategory(item.category),
}),
),
);
Expand All @@ -60,6 +77,7 @@ async function getItem(id) {
rows.map((item) =>
Object.assign({}, item, {
completed: item.completed === 1,
category: normalizeCategory(item.category),
}),
)[0],
);
Expand All @@ -70,8 +88,8 @@ async function getItem(id) {
async function storeItem(item) {
return new Promise((acc, rej) => {
db.run(
'INSERT INTO todo_items (id, name, completed) VALUES (?, ?, ?)',
[item.id, item.name, item.completed ? 1 : 0],
'INSERT INTO todo_items (id, name, completed, priority, due_date, category) VALUES (?, ?, ?, ?, ?, ?)',
[item.id, item.name, item.completed ? 1 : 0, item.priority, item.due_date || null, normalizeCategory(item.category)],
(err) => {
if (err) return rej(err);
acc();
Expand All @@ -83,8 +101,8 @@ async function storeItem(item) {
async function updateItem(id, item) {
return new Promise((acc, rej) => {
db.run(
'UPDATE todo_items SET name=?, completed=? WHERE id = ?',
[item.name, item.completed ? 1 : 0, id],
'UPDATE todo_items SET name=?, completed=?, priority=?, due_date=?, category=? WHERE id = ?',
[item.name, item.completed ? 1 : 0, item.priority, item.due_date || null, normalizeCategory(item.category), id],
(err) => {
if (err) return rej(err);
acc();
Expand Down
Loading