Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 23 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# deps
node_modules/
.pnp
.pnp.js

# Next.js
.next/
*.log
out/
build/

# misc
.DS_Store
dist/
*.pem
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*

# TypeScript
*.tsbuildinfo
next-env.d.ts
45 changes: 26 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
# Lightning Bounty Demo — Todo App
# Project Tracker

A small Next.js Todo app used as the **demo codebase** for [Lightning Bounty Marketplace](https://github.com/boaharis/lightning-bounty-map).
Demo codebase for the Lightning Bounties Marketplace pitch.

This repo exists so AI agents can bid on real GitHub issues against a real codebase. Issues here are auto-bountied via the marketplace; winning diffs land as PRs.
A project + task management SaaS app built with Next.js 14, React 18, TypeScript, and Tailwind CSS. All data lives in localStorage — no backend required.

## Stack

- Next.js 14 (App Router) + React 18 + TypeScript
- Tailwind CSS
- Vitest + Testing Library

## Running locally
## Quick start

```bash
npm install
npm run dev # http://localhost:3000
npm test # vitest in watch mode
npm test -- --run # one-shot
npm run dev # http://localhost:3001
npm test -- --run # run all tests once
npm run build # production build
```

## Why this repo
Login with `demo@example.com` and any password.

This is a **deterministic demo target**:
- Codebase is small (~12 files), so AI agents reliably produce mergeable diffs
- Tests are stable (no flaky timing issues)
- Issues are scoped (one feature per issue, narrow context)
## Known gaps (open bounties)

1. **Empty state illustration** — `ProjectList` shows text-only empty state, no illustration
2. **Bulk task actions** — no multi-select / bulk status change
3. **CSV import** — export works, import does not exist
4. **Drag-and-drop reordering** — tasks have no manual sort order
5. **Pomodoro timer** — no timer widget on task detail
6. **IndexedDB storage** — localStorage only, no large-data fallback
7. **Spanish translation incomplete** — `es.json` has ~60% coverage

## Stack

If you want to see the full marketplace flow, see the [main repo](https://github.com/boaharis/lightning-bounty-map).
- Next.js 14 (App Router)
- React 18
- TypeScript strict
- Tailwind CSS (Bauhaus aesthetic: hard borders, orange accent, Space Grotesk + DM Mono)
- localStorage for persistence (no backend)
- Vitest + Testing Library for tests
- Mock cookie-based auth (any password works for demo accounts)
55 changes: 55 additions & 0 deletions __tests__/ProjectForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ProjectForm } from "@/components/projects/ProjectForm";

describe("ProjectForm", () => {
it("renders name, description, and status fields", () => {
render(<ProjectForm onSave={vi.fn()} />);
expect(screen.getByLabelText(/project name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/status/i)).toBeInTheDocument();
});

it("shows validation error for short name", async () => {
render(<ProjectForm onSave={vi.fn()} />);
const nameInput = screen.getByLabelText(/project name/i);
fireEvent.change(nameInput, { target: { value: "X" } });
fireEvent.click(screen.getByRole("button", { name: /save project/i }));
expect(await screen.findByText(/at least 2 characters/i)).toBeInTheDocument();
});

it("calls onSave with correct data on valid submit", async () => {
const onSave = vi.fn();
render(<ProjectForm onSave={onSave} />);

fireEvent.change(screen.getByLabelText(/project name/i), {
target: { value: "My New Project" },
});
fireEvent.change(screen.getByLabelText(/description/i), {
target: { value: "A description" },
});
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ name: "My New Project", description: "A description" })
);
});

it("calls onCancel when cancel clicked", () => {
const onCancel = vi.fn();
render(<ProjectForm onSave={vi.fn()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});

it("pre-fills fields from initial prop", () => {
render(
<ProjectForm
initial={{ name: "Existing", description: "Old desc", status: "archived" }}
onSave={vi.fn()}
/>
);
expect(screen.getByLabelText(/project name/i)).toHaveValue("Existing");
expect(screen.getByLabelText(/description/i)).toHaveValue("Old desc");
});
});
57 changes: 57 additions & 0 deletions __tests__/TaskItem.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TaskItem } from "@/components/tasks/TaskItem";
import type { Task } from "@/lib/types";

const task: Task = {
id: "task-1",
projectId: "proj-1",
title: "Fix the bug",
description: "Critical production issue",
status: "blocked",
priority: "high",
tags: [{ id: "t1", name: "urgent", color: "#f97316" }],
assignee: "alice@example.com",
dueDate: "2024-04-01",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};

describe("TaskItem", () => {
it("renders task title", () => {
render(<TaskItem task={task} projectId="proj-1" />);
expect(screen.getByText("Fix the bug")).toBeInTheDocument();
});

it("renders status badge", () => {
render(<TaskItem task={task} projectId="proj-1" />);
expect(screen.getByText("Blocked")).toBeInTheDocument();
});

it("renders tag", () => {
render(<TaskItem task={task} projectId="proj-1" />);
expect(screen.getByText("urgent")).toBeInTheDocument();
});

it("renders assignee", () => {
render(<TaskItem task={task} projectId="proj-1" />);
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});

it("shows delete button when onDelete provided", () => {
render(<TaskItem task={task} projectId="proj-1" onDelete={vi.fn()} />);
expect(screen.getByLabelText(/delete task/i)).toBeInTheDocument();
});

it("does not show delete button when onDelete not provided", () => {
render(<TaskItem task={task} projectId="proj-1" />);
expect(screen.queryByLabelText(/delete task/i)).toBeNull();
});

it("calls onDelete with task id when delete clicked", () => {
const onDelete = vi.fn();
render(<TaskItem task={task} projectId="proj-1" onDelete={onDelete} />);
fireEvent.click(screen.getByLabelText(/delete task/i));
expect(onDelete).toHaveBeenCalledWith("task-1");
});
});
37 changes: 37 additions & 0 deletions __tests__/TaskList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TaskList } from "@/components/tasks/TaskList";
import type { Task } from "@/lib/types";

const tasks: Task[] = [
{ id: "1", projectId: "p1", title: "First task", description: "", status: "todo", priority: "high", tags: [], assignee: "", dueDate: null, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z" },
{ id: "2", projectId: "p1", title: "Second task", description: "Some details", status: "done", priority: "low", tags: [], assignee: "", dueDate: null, createdAt: "2024-01-02T00:00:00Z", updatedAt: "2024-01-02T00:00:00Z" },
{ id: "3", projectId: "p1", title: "Blocked task", description: "", status: "blocked", priority: "medium", tags: [], assignee: "alice", dueDate: null, createdAt: "2024-01-03T00:00:00Z", updatedAt: "2024-01-03T00:00:00Z" },
];

describe("TaskList", () => {
it("renders all tasks", () => {
render(<TaskList tasks={tasks} projectId="p1" />);
expect(screen.getByText("First task")).toBeInTheDocument();
expect(screen.getByText("Second task")).toBeInTheDocument();
expect(screen.getByText("Blocked task")).toBeInTheDocument();
});

it("shows empty state when no tasks", () => {
render(<TaskList tasks={[]} projectId="p1" />);
expect(screen.getByText(/no tasks yet/i)).toBeInTheDocument();
});

it("filters tasks by search", () => {
render(<TaskList tasks={tasks} projectId="p1" />);
const searchInput = screen.getByPlaceholderText(/search tasks/i);
fireEvent.change(searchInput, { target: { value: "second" } });
expect(screen.getByText("Second task")).toBeInTheDocument();
expect(screen.queryByText("First task")).toBeNull();
});

it("shows count of visible tasks", () => {
render(<TaskList tasks={tasks} projectId="p1" />);
expect(screen.getByText(/3 of 3 tasks/i)).toBeInTheDocument();
});
});
44 changes: 44 additions & 0 deletions __tests__/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ThemeToggle } from "@/components/ThemeToggle";

// Mock matchMedia — not available in jsdom
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

beforeEach(() => {
localStorage.clear();
document.documentElement.className = "";
});

describe("ThemeToggle", () => {
it("renders a button", () => {
render(<ThemeToggle />);
expect(screen.getByRole("button")).toBeInTheDocument();
});

it("has aria-label describing theme", () => {
render(<ThemeToggle />);
expect(screen.getByRole("button")).toHaveAttribute("aria-label");
});

it("cycles theme on click", () => {
render(<ThemeToggle />);
const btn = screen.getByRole("button");
const before = btn.getAttribute("aria-label");
fireEvent.click(btn);
const after = btn.getAttribute("aria-label");
expect(before).not.toBe(after);
});
});
52 changes: 52 additions & 0 deletions __tests__/ToastProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { ToastProvider, useToast } from "@/components/ui/ToastProvider";

function TestConsumer({ message, type }: { message: string; type?: "success" | "error" | "info" }) {
const { showToast } = useToast();
return (
<button onClick={() => showToast(message, type)} aria-label="trigger">
Trigger
</button>
);
}

describe("ToastProvider", () => {
it("renders children", () => {
render(
<ToastProvider>
<div>Hello</div>
</ToastProvider>
);
expect(screen.getByText("Hello")).toBeInTheDocument();
});

it("shows toast when showToast called", async () => {
render(
<ToastProvider>
<TestConsumer message="It worked!" type="success" />
</ToastProvider>
);
fireEvent.click(screen.getByLabelText("trigger"));
expect(await screen.findByText("It worked!")).toBeInTheDocument();
});

it("dismisses toast when X clicked", async () => {
render(
<ToastProvider>
<TestConsumer message="Dismiss me" />
</ToastProvider>
);
fireEvent.click(screen.getByLabelText("trigger"));
const dismissBtn = await screen.findByLabelText(/dismiss notification/i);
fireEvent.click(dismissBtn);
expect(screen.queryByText("Dismiss me")).toBeNull();
});

it("throws when useToast used outside provider", () => {
const err = console.error;
console.error = vi.fn();
expect(() => render(<TestConsumer message="oops" />)).toThrow();
console.error = err;
});
});
53 changes: 53 additions & 0 deletions __tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach } from "vitest";
import { findUser, MOCK_USERS } from "@/lib/auth/session";
import { validateLoginForm } from "@/lib/validation";

describe("findUser", () => {
it("returns user for known email", () => {
const user = findUser("alice@example.com", "anypassword");
expect(user).not.toBeNull();
expect(user?.email).toBe("alice@example.com");
});

it("returns null for unknown email", () => {
const user = findUser("unknown@example.com", "pass");
expect(user).toBeNull();
});

it("returns user for demo@example.com", () => {
const user = findUser("demo@example.com", "demo");
expect(user).not.toBeNull();
expect(user?.name).toBe("Demo User");
});

it("any password works (demo mock)", () => {
const user1 = findUser("alice@example.com", "wrongpassword");
const user2 = findUser("alice@example.com", "correct");
expect(user1).not.toBeNull();
expect(user2).not.toBeNull();
});
});

describe("validateLoginForm", () => {
it("accepts valid input", () => {
const result = validateLoginForm({ email: "alice@example.com", password: "secret" });
expect(result.valid).toBe(true);
});

it("rejects missing @", () => {
const result = validateLoginForm({ email: "notanemail", password: "secret" });
expect(result.valid).toBe(false);
expect(result.errors.email).toBeTruthy();
});

it("rejects short password", () => {
const result = validateLoginForm({ email: "a@b.com", password: "abc" });
expect(result.valid).toBe(false);
expect(result.errors.password).toBeTruthy();
});

it("rejects non-object", () => {
const result = validateLoginForm(null);
expect(result.valid).toBe(false);
});
});
Loading