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
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,31 @@
# dead-simple-crm
A minimal, opinionated CRM that does contacts, companies, deals, and notes — nothing more. Clean UI, fast search, CSV import/export. Built for people who want a CRM without the bloat of Salesforce or HubSpot. Every feature is a discrete bounty: filters, email integration, pipeline views, reporting.
# Dead Simple CRM

A minimal CRM contact-list view with sortable columns, pagination, and a clean no-build frontend.

## Bounty scope

Shipyard bounty `bnt_6YVRvFJX`: **Build contacts list view**

Implemented:

- Contacts table with columns for name, email, company, and last updated.
- Clickable sortable column headers with ascending/descending state.
- Paginated contact rows with next/previous controls and range text.
- Minimal responsive design and an app thumbnail at `public/thumbnail.svg`.
- Unit coverage for sort and pagination behavior.

## Run locally

Open `index.html` in a browser, or serve the folder with any static server:

```bash
python -m http.server 4173
```

Then visit `http://localhost:4173`.

## Verify

```bash
npm test
```
64 changes: 64 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Dead Simple CRM contact list view" />
<meta property="og:image" content="./public/thumbnail.svg" />
<link rel="icon" href="./public/thumbnail.svg" type="image/svg+xml" />
<link rel="stylesheet" href="./src/styles.css" />
<title>Dead Simple CRM</title>
</head>
<body>
<main class="shell">
<section class="hero" aria-labelledby="page-title">
<div>
<p class="eyebrow">Dead Simple CRM</p>
<h1 id="page-title">Contacts</h1>
<p class="lede">
A focused contact list for people who want names, companies, and follow-up freshness without CRM bloat.
</p>
</div>
<img class="thumbnail" src="./public/thumbnail.svg" alt="Dead Simple CRM thumbnail" />
</section>

<section class="panel" aria-labelledby="contacts-title">
<div class="panel__header">
<div>
<h2 id="contacts-title">Contact list</h2>
<p id="range-text" class="muted" aria-live="polite"></p>
</div>
<label class="page-size">
Rows
<select id="page-size" aria-label="Rows per page">
<option value="5">5</option>
<option value="10">10</option>
</select>
</label>
</div>

<div class="table-wrap">
<table>
<thead>
<tr>
<th><button class="sort-button" data-sort-key="name" type="button">Name <span aria-hidden="true"></span></button></th>
<th><button class="sort-button" data-sort-key="email" type="button">Email <span aria-hidden="true"></span></button></th>
<th><button class="sort-button" data-sort-key="company" type="button">Company <span aria-hidden="true"></span></button></th>
<th><button class="sort-button" data-sort-key="lastUpdated" type="button">Last updated <span aria-hidden="true"></span></button></th>
</tr>
</thead>
<tbody id="contacts-body"></tbody>
</table>
</div>

<nav class="pagination" aria-label="Contact pagination">
<button id="prev-page" type="button">Previous</button>
<span id="page-text" class="muted" aria-live="polite"></span>
<button id="next-page" type="button">Next</button>
</nav>
</section>
</main>

<script type="module" src="./src/main.js"></script>
</body>
</html>
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "dead-simple-crm",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "node tests/contact-utils.test.mjs"
}
}
37 changes: 37 additions & 0 deletions public/thumbnail.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions src/contact-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export function sortContacts(contacts, sortKey, direction = "asc") {
const multiplier = direction === "desc" ? -1 : 1;

return [...contacts].sort((left, right) => {
const leftValue = normalizeSortValue(left[sortKey], sortKey);
const rightValue = normalizeSortValue(right[sortKey], sortKey);

if (leftValue < rightValue) return -1 * multiplier;
if (leftValue > rightValue) return 1 * multiplier;
return left.name.localeCompare(right.name);
});
}

export function paginateContacts(contacts, page, pageSize) {
const totalPages = Math.max(1, Math.ceil(contacts.length / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
const start = (safePage - 1) * pageSize;

return {
rows: contacts.slice(start, start + pageSize),
page: safePage,
pageSize,
totalPages,
totalRows: contacts.length,
rangeStart: contacts.length === 0 ? 0 : start + 1,
rangeEnd: Math.min(start + pageSize, contacts.length)
};
}

export function formatUpdatedDate(value) {
return new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric"
}).format(new Date(value));
}

function normalizeSortValue(value, sortKey) {
if (sortKey === "lastUpdated") {
return new Date(value).getTime();
}

return String(value ?? "").toLowerCase();
}
92 changes: 92 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { formatUpdatedDate, paginateContacts, sortContacts } from "./contact-utils.js";

const contacts = [
{ name: "Maya Patel", email: "maya@northstar.dev", company: "Northstar Labs", lastUpdated: "2026-05-26T14:05:00Z" },
{ name: "Jon Bell", email: "jon@quietcrm.co", company: "Quiet CRM", lastUpdated: "2026-05-24T10:30:00Z" },
{ name: "Sofia Chen", email: "sofia@ledgerloop.io", company: "LedgerLoop", lastUpdated: "2026-05-28T18:15:00Z" },
{ name: "Arun Desai", email: "arun@fieldbase.ai", company: "Fieldbase", lastUpdated: "2026-05-22T09:45:00Z" },
{ name: "Elena Torres", email: "elena@dockwise.app", company: "Dockwise", lastUpdated: "2026-05-21T16:20:00Z" },
{ name: "Nate Brooks", email: "nate@signalforge.dev", company: "Signal Forge", lastUpdated: "2026-05-20T11:00:00Z" },
{ name: "Priya Rao", email: "priya@brightstack.co", company: "Brightstack", lastUpdated: "2026-05-27T12:10:00Z" },
{ name: "Theo Martin", email: "theo@yardbird.tools", company: "Yardbird Tools", lastUpdated: "2026-05-19T08:25:00Z" },
{ name: "Grace Okafor", email: "grace@orbitnotes.com", company: "Orbit Notes", lastUpdated: "2026-05-23T13:35:00Z" },
{ name: "Iris Walker", email: "iris@cloverbase.io", company: "Cloverbase", lastUpdated: "2026-05-25T15:50:00Z" },
{ name: "Sam Rivera", email: "sam@tinyops.dev", company: "TinyOps", lastUpdated: "2026-05-18T17:05:00Z" },
{ name: "Leah Kim", email: "leah@humblepipeline.com", company: "Humble Pipeline", lastUpdated: "2026-05-29T07:40:00Z" }
];

const state = {
page: 1,
pageSize: 5,
sortKey: "lastUpdated",
sortDirection: "desc"
};

const contactsBody = document.querySelector("#contacts-body");
const pageSizeSelect = document.querySelector("#page-size");
const pageText = document.querySelector("#page-text");
const rangeText = document.querySelector("#range-text");
const previousButton = document.querySelector("#prev-page");
const nextButton = document.querySelector("#next-page");
const sortButtons = [...document.querySelectorAll(".sort-button")];

function render() {
const sortedContacts = sortContacts(contacts, state.sortKey, state.sortDirection);
const pageData = paginateContacts(sortedContacts, state.page, state.pageSize);
state.page = pageData.page;

contactsBody.innerHTML = pageData.rows
.map(
(contact) => `
<tr>
<td>
<strong>${contact.name}</strong>
</td>
<td><a href="mailto:${contact.email}">${contact.email}</a></td>
<td>${contact.company}</td>
<td><time datetime="${contact.lastUpdated}">${formatUpdatedDate(contact.lastUpdated)}</time></td>
</tr>
`
)
.join("");

pageText.textContent = `Page ${pageData.page} of ${pageData.totalPages}`;
rangeText.textContent = `Showing ${pageData.rangeStart}-${pageData.rangeEnd} of ${pageData.totalRows} contacts`;
previousButton.disabled = pageData.page === 1;
nextButton.disabled = pageData.page === pageData.totalPages;

sortButtons.forEach((button) => {
const icon = button.querySelector("span");
const isActive = button.dataset.sortKey === state.sortKey;
button.setAttribute("aria-sort", isActive ? state.sortDirection : "none");
icon.textContent = isActive ? (state.sortDirection === "asc" ? "↑" : "↓") : "";
});
}

sortButtons.forEach((button) => {
button.addEventListener("click", () => {
const nextKey = button.dataset.sortKey;
state.sortDirection = state.sortKey === nextKey && state.sortDirection === "asc" ? "desc" : "asc";
state.sortKey = nextKey;
state.page = 1;
render();
});
});

pageSizeSelect.addEventListener("change", (event) => {
state.pageSize = Number(event.target.value);
state.page = 1;
render();
});

previousButton.addEventListener("click", () => {
state.page -= 1;
render();
});

nextButton.addEventListener("click", () => {
state.page += 1;
render();
});

render();
Loading