DEV Community

AJ
AJ

Posted on

Ekehi Engineering Sprint 3 — Detail Pages, Save States, and Leading a Sprint

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 in opportunity.utils.js that serialises filter state into URL query params
  • Wiring onFilterChange on both pages to call the API with the constructed query string
  • Extracting a shared formatDate utility to remove a duplicate formatDeadline function 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 requireRole middleware 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);
}
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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%);
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)