From df361b2578fbdc2bb5b6a1f2d6de6ea1a242961d Mon Sep 17 00:00:00 2001 From: strongkeep-debug Date: Fri, 29 May 2026 04:30:49 -0700 Subject: [PATCH] Build contacts list view --- README.md | 33 +++++- index.html | 64 ++++++++++++ package.json | 9 ++ public/thumbnail.svg | 37 +++++++ src/contact-utils.js | 44 ++++++++ src/main.js | 92 +++++++++++++++++ src/styles.css | 189 +++++++++++++++++++++++++++++++++++ tests/contact-utils.test.mjs | 39 ++++++++ 8 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 index.html create mode 100644 package.json create mode 100644 public/thumbnail.svg create mode 100644 src/contact-utils.js create mode 100644 src/main.js create mode 100644 src/styles.css create mode 100644 tests/contact-utils.test.mjs diff --git a/README.md b/README.md index b2bc9ba..309707a 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..2ef7037 --- /dev/null +++ b/index.html @@ -0,0 +1,64 @@ + + + + + + + + + + Dead Simple CRM + + +
+
+
+

Dead Simple CRM

+

Contacts

+

+ A focused contact list for people who want names, companies, and follow-up freshness without CRM bloat. +

+
+ Dead Simple CRM thumbnail +
+ +
+
+
+

Contact list

+

+
+ +
+ +
+ + + + + + + + + + +
+
+ + +
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c3eb3a8 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "dead-simple-crm", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node tests/contact-utils.test.mjs" + } +} diff --git a/public/thumbnail.svg b/public/thumbnail.svg new file mode 100644 index 0000000..6eed632 --- /dev/null +++ b/public/thumbnail.svg @@ -0,0 +1,37 @@ + + Dead Simple CRM contact list thumbnail + A dark slate dashboard card with contact rows, soft green highlights, and the words Dead Simple CRM. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dead Simple CRM + Sortable contacts. Clean follow-up. + diff --git a/src/contact-utils.js b/src/contact-utils.js new file mode 100644 index 0000000..4db1e7e --- /dev/null +++ b/src/contact-utils.js @@ -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(); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..1397fa2 --- /dev/null +++ b/src/main.js @@ -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) => ` + + + ${contact.name} + + ${contact.email} + ${contact.company} + + + ` + ) + .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(); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..1f51bef --- /dev/null +++ b/src/styles.css @@ -0,0 +1,189 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0f172a; + color: #e5edf7; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top right, rgba(34, 197, 94, 0.2), transparent 34rem), + radial-gradient(circle at bottom left, rgba(56, 189, 248, 0.13), transparent 30rem), + #0f172a; +} + +button, +select { + font: inherit; +} + +.shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 48px 0; +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; +} + +.eyebrow { + margin: 0 0 8px; + color: #86efac; + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 10px; + font-size: clamp(2.5rem, 8vw, 5.5rem); + line-height: 0.92; +} + +h2 { + margin-bottom: 6px; +} + +.lede { + max-width: 620px; + margin-bottom: 0; + color: #a8b3c7; + font-size: 1.08rem; +} + +.thumbnail { + width: min(280px, 30vw); + border-radius: 22px; + box-shadow: 0 24px 90px rgba(0, 0, 0, 0.35); +} + +.panel { + overflow: hidden; + border: 1px solid #263244; + border-radius: 24px; + background: rgba(15, 23, 42, 0.78); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24); +} + +.panel__header, +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 22px; +} + +.muted { + margin: 0; + color: #94a3b8; +} + +.page-size { + display: flex; + align-items: center; + gap: 8px; + color: #cbd5e1; +} + +select, +.pagination button { + border: 1px solid #334155; + border-radius: 999px; + background: #111827; + color: #f8fafc; + padding: 8px 12px; +} + +.pagination button { + cursor: pointer; + min-width: 92px; +} + +.pagination button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border-top: 1px solid #263244; + padding: 16px 22px; + text-align: left; + white-space: nowrap; +} + +th { + background: rgba(17, 24, 39, 0.76); +} + +tbody tr:hover { + background: rgba(34, 197, 94, 0.05); +} + +a { + color: #7dd3fc; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.sort-button { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + color: #cbd5e1; + cursor: pointer; + font-weight: 800; + padding: 0; +} + +.sort-button span { + min-width: 1ch; + color: #86efac; +} + +@media (max-width: 760px) { + .hero, + .panel__header, + .pagination { + align-items: flex-start; + flex-direction: column; + } + + .thumbnail { + width: 100%; + max-width: 360px; + } +} diff --git a/tests/contact-utils.test.mjs b/tests/contact-utils.test.mjs new file mode 100644 index 0000000..64725ab --- /dev/null +++ b/tests/contact-utils.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { paginateContacts, sortContacts } from "../src/contact-utils.js"; + +const fixtures = [ + { name: "Beta", email: "beta@example.com", company: "B Co", lastUpdated: "2026-05-01T00:00:00Z" }, + { name: "Alpha", email: "alpha@example.com", company: "A Co", lastUpdated: "2026-05-03T00:00:00Z" }, + { name: "Gamma", email: "gamma@example.com", company: "C Co", lastUpdated: "2026-05-02T00:00:00Z" } +]; + +assert.deepEqual( + sortContacts(fixtures, "name", "asc").map((contact) => contact.name), + ["Alpha", "Beta", "Gamma"], + "sorts contact names ascending" +); + +assert.deepEqual( + sortContacts(fixtures, "lastUpdated", "desc").map((contact) => contact.name), + ["Alpha", "Gamma", "Beta"], + "sorts last updated dates descending" +); + +assert.deepEqual( + paginateContacts(fixtures, 2, 2), + { + rows: [fixtures[2]], + page: 2, + pageSize: 2, + totalPages: 2, + totalRows: 3, + rangeStart: 3, + rangeEnd: 3 + }, + "paginates contacts and reports row range" +); + +assert.equal(paginateContacts(fixtures, 99, 2).page, 2, "clamps page to the last page"); +assert.equal(paginateContacts([], 1, 5).rangeStart, 0, "empty pagination has zero range start"); + +console.log("contact utilities: ok");