This is part of an ongoing series documenting the build of **Ekehi, a platform connecting African entrepreneurs with funding opportunities, training programmes, and business resources. I'm AJ, the Engineering Lead on the project.
Previously on Ekehi Engineering — What We're Building
Ekehi is a full-stack web app built with vanilla JavaScript on the frontend (Netlify), an Express.js API on Render, and Supabase (PostgreSQL + Auth) as the database and auth layer. The stack is intentionally lean — no frontend framework — which means every UX pattern we reach for has to be hand-rolled.
Sprint 3 was our most feature-dense week yet. Twelve issues, seven contributors, and a full redesign of the core user-facing pages — all landed and merged within the week.
What I Personally Shipped
Note Again: Going through the repository while reading the article will provide more context
1. Fixed the Broken Filter and Search Wiring (PR #83)
Coming out of Sprint 2, the opportunities and resources pages had a filtering UI — dropdowns, search bars, the works — but none of it was actually working. Filter selections were updating local state but never being sent to the API. Users could interact with every control and nothing would happen.
The fix involved:
- Building a shared
buildQueryString(filters)utility inopportunity.utils.jsthat serialises filter state into URL query params - Wiring
onFilterChangeon both pages to call the API with the constructed query string - Extracting a shared
formatDateutility to remove a duplicateformatDeadlinefunction that existed in three places
This was the first PR of the week and it unblocked the rest of the sprint. Can't really build on top of a broken foundation.
2. Structural Backend and Client Updates (PR #97)
Before the team could pick up their sprint issues, I needed to lay the groundwork — new pages scaffolded, backend routes wired, shared constants extended, and the admin dashboard built out. This PR covered:
- Scaffolding the admin dashboard (
/admin) with a content queue and review pages - Wiring backend routes for content management (create, update, approve opportunities)
- Setting up the
requireRolemiddleware for role-based access control - Creating the sprint 3 issue backlog on GitHub and assigning every task to the right team member
Though we had to shift the use of the admin to next week, getting this started will make the load easier.
3. Opportunity Detail Page Redesign + Save Feature (PR #111)
This was the biggest lift of the week and touched both the frontend and backend.
Two-column layout with sticky aside
The old detail page was a single-column wall of text. The new design called for a content card on the left and a sticky action sidebar on the right. This was implemented with CSS Grid (Grid is Supreme 👑):
#detail-root {
display: grid;
grid-template-columns: 1fr 296px;
align-items: start;
gap: var(--space-6);
}
.detail-aside {
position: sticky;
top: var(--space-6);
}
The grid only reveals after the API response arrives — before that, a loading text spans the full width using grid-column: 1 / -1. Once data loads, root.classList.add("is-loaded") triggers a CSS rule that unhides both columns.
Eligibility criteria as a bullet list
The eligibility criteria came back from the API as a single long string. Rather than dump it into a <p> tag, it was split on periods and each sentence rendered as a <li>:
function renderEligibilityList(criteria) {
const items = criteria.split(".").map(s => s.trim()).filter(Boolean);
return `<ul class="eligibility-list">
${items.map(item => `<li>${escapeHtml(item)}</li>`).join("")}
</ul>`;
}
Correct save state on page load
This was the most technically interesting problem of the week. The save button needed to show "Saved" if the logged-in user had already bookmarked the opportunity — but the detail route (GET /opportunities/:id) was a public, unauthenticated endpoint, so req.user was always undefined.
My solution: add an optionalAuth middleware that reads the JWT if present but never blocks the request if it's missing.
const optionalAuth = async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) return next();
const token = authHeader.split(" ")[1];
const { data } = await supabase.auth.getUser(token);
if (data?.user) req.user = data.user;
return next();
};
With req.user now optionally populated, I updated getOpportunityById in the service layer to accept a userId and run a secondary query against saved_opportunities:
const getOpportunityById = async (id, userId = null) => {
const { data, error } = await supabase
.from("funding_opportunities")
.select("*")
.eq("id", id)
.eq("approval_status", "approved")
.single();
if (error) throw error;
if (!data) return null;
let is_saved = false;
if (userId) {
const { data: saved } = await supabase
.from("saved_opportunities")
.select("opportunity_id")
.eq("user_id", userId)
.eq("opportunity_id", id)
.maybeSingle();
is_saved = !!saved;
}
return { ...data, is_saved };
};
The frontend now reads opp.is_saved directly from the detail response — no second API call, no loading flicker.
Contributor page skeleton loading
The contributors grid was an empty section until JavaScript loaded and fetched data. 14 skeleton cards are pre-rendered in static HTML, so the grid shape is visible immediately:
When real data arrives, container.innerHTML = "" clears the skeletons and real cards are appended.
Technical Decisions and Why
optionalAuth over a separate saved-state endpoint
My first instinct was to add a GET /opportunities/:id/save endpoint that the frontend could call after load. The problem: that's an extra network round-trip on every detail page visit, even for logged-out users who will never save anything. Embedding is_saved in the detail response costs one lightweight DB query only when the user is logged in, and zero overhead otherwise.
style.display instead of the hidden attribute
The tabs element on the opportunities page needed to be hidden for logged-out users. Setting element.hidden = true wasn't working. The reason: a .flex utility class in our stylesheet was setting display: flex with higher specificity than the [hidden] attribute selector. Using element.style.display = "none" overrides both — inline styles always win.
Skeleton cards in static HTML, not JavaScript
Generating skeletons in JS means users see an empty grid until the script runs, parses, and executes. Putting them in the HTML means they're visible on the first paint, before any JavaScript has been fetched. The real cards replace them cleanly; the user never sees a layout shift.
Breadcrumb outside #detail-root
The detail page renders by setting innerHTML on a root container. If the breadcrumb lives inside that container, every re-render wipes and re-creates it. Moving it to static HTML above the root means it's always present and never touched by JavaScript.
Bugs I Encountered and Fixed
Modal appearing in the top-left corner
After building the save auth-gate modal with the native <dialog> element, it was rendering in the top-left corner instead of centred. The native <dialog> centres itself using margin: auto — but our global CSS reset included * { margin: 0 }, which zeroed it out.
Fix: Explicitly position the modal with CSS transforms:
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
Modal closing when clicking inside the content
The backdrop click listener was using e.target === dialog, which fires for both a click on the backdrop and a click on the dialog's own padding. So clicking anywhere near the edge of the modal content was dismissing it.
Fix: Use getBoundingClientRect to check whether the click coordinates are actually outside the dialog box:
this.#dialog.addEventListener("click", (e) => {
const rect = this.#dialog.getBoundingClientRect();
const isBackdrop =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (isBackdrop) this.close();
});
is_saved always returning false
After adding is_saved to the service, the save button on the detail page was still showing "Save" even for saved opportunities. The middleware chain was correct but req.user was always undefined.
Root cause: the route GET /:id had no auth middleware at all — req.user was never set. The fix was adding optionalAuth to the route, which only runs the Supabase token check when an Authorization header is actually present.
Saved tab count always showing zero on page load
When a logged-in user landed on the opportunities page, the "Saved" tab badge showed 0 even if they had saved items. The count was only updated after they switched to the tab and triggered a fetch.
Fix: Fire a prefetch request on page load using ?limit=1 to get the total count from pagination metadata, without fetching all the actual records:
async function prefetchSavedCount() {
const res = await api.get("/opportunities/saved?limit=1").catch(() => null);
if (res?.meta?.total !== undefined) updateSavedTabCount(res.meta.total);
}
What I Coordinated as Engineering Lead
Beyond my own code, I created and assigned 12 issues at the start of the week that the full team executed on:
| Issue | Feature | Assignee |
|---|---|---|
| #84 | Navigation update — Submissions link + Post a Job button | Florence |
| #85 | Hero section full redesign | Esther |
| #86 | About section — dark purple full-width banner | Marion |
| #87 | Value proposition — full-width image with colour wash | Victor |
| #88 | What-we-offer — interactive list with contextual image | Oluchi |
| #89 | FAQ section | Iyobosa |
| #92 | Signup step 1 — multi-step identity form | Marion |
| #93 | Signup step 2 — password creation | Marion |
| #94 | Login page redesign | Florence |
| #90 | Opportunity detail page redesign | Me |
| #91 | Save auth-gate modal | Me |
| #96 | Backend bookmark feature | Me |
All 12 issues were closed by end of week.
Sprint Reflections
What worked well
Breaking the sprint into small, scoped issues before the week started meant every team member knew exactly what to build and there were no blockers waiting on decisions. The team executed fast — most PRs were open and merged within 24 hours of the issue being created.
The decision to keep the stack simple (no framework, no build tool) paid off this sprint. Everyone could read and understand each other's code. There was no configuration friction when reviewing PRs.
What I would do differently
My own commit messages were too vague. "chore: update opportunities detail page" tells you nothing about why things changed. I was kinda lazy and in a time crunch to fix bugs to be kinda detailed in some commits. Next sprint I want to write commit messages that explain the reasoning, not just the file that changed.
I also want to define API contracts (request/response shape) before frontend work begins. This sprint, the is_saved field was added mid-week because the frontend hit a wall — if that had been in the spec from day one, the detail page would have been complete two days earlier.
What I'm Most Proud Of
The optionalAuth middleware is a small thing but it's the right thing. It solves a real problem — "how do you personalise a response on a public route?" — cleanly, without compromising the security model or adding a separate endpoint. The route stays public, unauthenticated users get a fast response with is_saved: false, and logged-in users get the correct state with one lightweight extra query. No frontend complexity, no extra round-trip. That's the kind of decision that doesn't show up in screenshots but makes the product feel solid.
Team
Big shoutout to the team for executing an ambitious sprint cleanly:
- Marion (@MarionBraide) — About section, multi-step signup flow, bug fixes
- Esther (@first-afk) — Hero section redesign
- Victor (@Okoukoni-Victor) — Value proposition section
- Oluchi (@luchiiii) — What-we-offer section
- Iyobosa (@Fhave) — FAQ section
- Florence (@Florence-code-hub) — Navigation update, login page redesign
GitHub repository: github.com/Tabi-Project/Ekehi
Closing Thought
Sprint 3 was kinda a blur with how feature densed it was. It reminded me that engineering leadership is less about writing the most code and more about making sure the right code gets written by the right people at the right time. My most valuable work this week wasn't a feature, it was creating 12 well-scoped issues, so that six teammates could ship confidently without waiting on me.
The best sprints feel boring from the outside. No fires, no blockers, no heroics — just a team moving steadily through a well-defined backlog. That's what we're building toward, and this week got us closer.
Top comments (0)