<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: AJ</title>
    <description>The latest articles on Forem by AJ (@aj1732).</description>
    <link>https://forem.com/aj1732</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1108129%2Fcd24bfec-1ff9-40ee-8ead-9b1a9a515224.jpeg</url>
      <title>Forem: AJ</title>
      <link>https://forem.com/aj1732</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/aj1732"/>
    <language>en</language>
    <item>
      <title>Building Ekehi — Week 4: Admin Tools, Profile Images, Submissions, and Closing the Loop</title>
      <dc:creator>AJ</dc:creator>
      <pubDate>Sat, 28 Mar 2026 09:54:06 +0000</pubDate>
      <link>https://forem.com/aj1732/building-ekehi-week-4-admin-tools-profile-images-submissions-and-closing-the-loop-3n1o</link>
      <guid>https://forem.com/aj1732/building-ekehi-week-4-admin-tools-profile-images-submissions-and-closing-the-loop-3n1o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is part of an ongoing series documenting the build of &lt;strong&gt;Ekehi&lt;/strong&gt;, a platform connecting African entrepreneurs with funding opportunities, training programmes, and business resources. I'm AJ, the Engineering Lead on the project.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Where We Left Off
&lt;/h2&gt;

&lt;p&gt;Sprint 3 gave us a polished opportunities section — redesigned detail page, working save feature, correct saved state on page load, and a contributors page with skeleton loading. Sprint 4 had a different character. Less redesign, more &lt;em&gt;completion&lt;/em&gt;. The goal was to close out everything still outstanding: ship profile image support end-to-end, build the submissions page, wire the resources section to the real API, and deliver a functioning admin content management system with approval, rejection, and delete.&lt;/p&gt;

&lt;p&gt;Seven issues created on Monday morning. All closed by Thursday. Issue #80 — the long-running tracker for wiring all frontend pages to the real API — finally closed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Personally Shipped
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Profile Image Upload — Backend (PR #114)
&lt;/h3&gt;

&lt;p&gt;The first task of the week was building profile image upload into the auth and profile flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was built:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added &lt;code&gt;profile_image_path TEXT NULL&lt;/code&gt; to the &lt;code&gt;profiles&lt;/code&gt; table via a Supabase migration&lt;/li&gt;
&lt;li&gt;Installed &lt;code&gt;multer&lt;/code&gt; for &lt;code&gt;multipart/form-data&lt;/code&gt; parsing with memory storage (5MB limit, JPEG/PNG/WebP only)&lt;/li&gt;
&lt;li&gt;Created a shared &lt;code&gt;upload.middleware.js&lt;/code&gt; with the multer config, reusable across routes&lt;/li&gt;
&lt;li&gt;Updated &lt;code&gt;POST /auth/signup&lt;/code&gt; to accept an optional &lt;code&gt;profileImage&lt;/code&gt; file alongside existing form fields&lt;/li&gt;
&lt;li&gt;Added &lt;code&gt;GET /profile&lt;/code&gt; — returns the authenticated user's full profile&lt;/li&gt;
&lt;li&gt;Added &lt;code&gt;PATCH /profile&lt;/code&gt; — updates &lt;code&gt;firstName&lt;/code&gt;, &lt;code&gt;lastName&lt;/code&gt;, and/or &lt;code&gt;profileImage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Built &lt;code&gt;storage.utils.js&lt;/code&gt; with shared helpers: &lt;code&gt;uploadProfileImage&lt;/code&gt;, &lt;code&gt;getPublicImageUrl&lt;/code&gt;, &lt;code&gt;deleteImage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Images stored in the &lt;code&gt;ekehi-assets&lt;/code&gt; bucket at &lt;code&gt;profile-images/&amp;lt;userId&amp;gt;/avatar.&amp;lt;ext&amp;gt;&lt;/code&gt; with upsert&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One deliberate design decision: the database stores the &lt;strong&gt;storage path&lt;/strong&gt;, not the full URL. The full URL is derived at read-time via &lt;code&gt;getPublicUrl()&lt;/code&gt;. This decouples the data from the infrastructure — if the bucket or region ever changes, nothing in the database needs updating.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Profile Image Upload — Frontend (PR #130)
&lt;/h3&gt;

&lt;p&gt;With the backend in place, the nav avatar now fetches the user's profile on mount, reads &lt;code&gt;profile_image_path&lt;/code&gt;, derives the public URL, and renders it. If the image is absent or fails to load, the placeholder icon shows — the user never sees a broken image element.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Admin Content Management — Full Lifecycle (PR #136)
&lt;/h3&gt;

&lt;p&gt;This was the most significant backend work of the sprint. Content submitted by users needed to be reviewable, approvable, rejectable, and deletable by admins — all from a single admin interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval and rejection flow&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Built &lt;code&gt;PATCH /admin/{contentType}/{id}/review&lt;/code&gt; accepting &lt;code&gt;{ decision: "approved" }&lt;/code&gt; or &lt;code&gt;{ decision: "rejected", feedback: "..." }&lt;/code&gt;. On approval, &lt;code&gt;approval_status&lt;/code&gt; is updated to &lt;code&gt;"approved"&lt;/code&gt; and the item immediately appears on public endpoints. On rejection, feedback is required — enforced on both client and server.&lt;/p&gt;

&lt;p&gt;Every review writes an audit record to the &lt;code&gt;content_reviews&lt;/code&gt; table: who reviewed it, when, the decision, and any feedback. This gives the admin team a full history of every content decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete functionality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Built &lt;code&gt;DELETE /admin/{contentType}/{id}&lt;/code&gt; protected by &lt;code&gt;adminGuard&lt;/code&gt;. Works on any approval status. The review page shows a Danger Zone card with a delete button. Clicking it opens a confirmation dialog before the request fires — permanent deletion cannot be triggered by a stray click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue status filters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Previously the admin queue only showed pending content. Approved and rejected content was unreachable from the admin UI. Added a two-row tab system: status tabs (Pending / Approved / Rejected) above type tabs (All / Funding / Training / Guide / Template). The full content lifecycle is now manageable from one place.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. RBAC Implementation (Issue #120)
&lt;/h3&gt;

&lt;p&gt;Completed the role-based access control system. Added a &lt;code&gt;CHECK&lt;/code&gt; constraint to the &lt;code&gt;profiles&lt;/code&gt; table enforcing only valid roles: &lt;code&gt;super-admin&lt;/code&gt;, &lt;code&gt;data-manager&lt;/code&gt;, &lt;code&gt;content-editor&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;. The &lt;code&gt;adminGuard&lt;/code&gt; middleware — which protects all &lt;code&gt;/admin/*&lt;/code&gt; routes — validates both authentication and role before any admin operation executes.&lt;/p&gt;

&lt;p&gt;All destructive operations (approve, reject, delete) sit behind this guard. The separation is clean: public routes, authenticated user routes, and admin routes are three distinct layers with no overlap.&lt;/p&gt;




&lt;h3&gt;
  
  
  5. Submissions Page, Resources Wiring, and Clean Code (PR #136)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Submissions page (&lt;code&gt;/submissions/&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Built a protected 3-section accordion form for authenticated users to submit funding opportunities. The sections are: About the Opportunity, Programme Details, and About the Organiser — so the form doesn't feel like an overwhelming wall of fields.&lt;/p&gt;

&lt;p&gt;Key decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auth-gated on the client: unauthenticated visits redirect to login with &lt;code&gt;?redirect=/submissions/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dropdowns use the existing &lt;code&gt;Dropdown&lt;/code&gt; component, not native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, keeping the UI consistent&lt;/li&gt;
&lt;li&gt;Submitted opportunities land as &lt;code&gt;approval_status: "pending"&lt;/code&gt; — straight into the admin review queue&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /opportunities&lt;/code&gt; is open to any authenticated user, not just admins, so the community can contribute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Guides and templates wired to real API&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both sections were rendering hardcoded placeholder data. This PR replaced all of that with real API calls, loading skeletons, and proper error states.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean code pass&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extracted &lt;code&gt;parsePagination&lt;/code&gt; as a shared utility — removes repeated &lt;code&gt;Math.max/min&lt;/code&gt; clamping across controllers&lt;/li&gt;
&lt;li&gt;Extracted &lt;code&gt;extractBearerToken&lt;/code&gt; in &lt;code&gt;auth.middleware.js&lt;/code&gt; — used by both &lt;code&gt;requireAuth&lt;/code&gt; and &lt;code&gt;optionalAuth&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;DRY'd the dropdown mount calls on the submissions form&lt;/li&gt;
&lt;li&gt;Fixed a misleading comment on the rate limiter: said &lt;code&gt;15 min window&lt;/code&gt;, actual value was &lt;code&gt;2 min&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Coordinated as Engineering Lead
&lt;/h2&gt;

&lt;p&gt;Seven issues created on Monday, all closed by end of week:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Assignee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#113&lt;/td&gt;
&lt;td&gt;Backend: profile image upload&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#115&lt;/td&gt;
&lt;td&gt;Frontend: build training detail page&lt;/td&gt;
&lt;td&gt;Marion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#116&lt;/td&gt;
&lt;td&gt;Frontend: add Guides section to resources page&lt;/td&gt;
&lt;td&gt;Victor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#117&lt;/td&gt;
&lt;td&gt;Frontend: add Templates section to resources page&lt;/td&gt;
&lt;td&gt;Florence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#118&lt;/td&gt;
&lt;td&gt;Frontend: build guide detail page&lt;/td&gt;
&lt;td&gt;Oluchi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#119&lt;/td&gt;
&lt;td&gt;Frontend: build template detail page&lt;/td&gt;
&lt;td&gt;Esther&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#120&lt;/td&gt;
&lt;td&gt;Complete RBAC implementation&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The team also shipped two bug fixes beyond their assigned issues — Iyobosa fixed filter wrapping on the training page and added empty-state messaging, and Gabriel shipped loading skeletons for opportunity and training card lists.&lt;/p&gt;




&lt;h2&gt;
  
  
  Technical Decisions and Why
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Store storage path, not full URL&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first implementation stored the full Supabase Storage URL in the database. The problem: that URL is tied to the bucket name, project ID, and region. If any of those change, every stored URL breaks. Storing just the path (&lt;code&gt;profile-images/&amp;lt;userId&amp;gt;/avatar.jpeg&lt;/code&gt;) and deriving the URL at read-time via &lt;code&gt;getPublicUrl()&lt;/code&gt; means the database is infrastructure-agnostic. &lt;code&gt;getPublicUrl()&lt;/code&gt; is synchronous — no extra network call, no performance cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed filename &lt;code&gt;avatar.&amp;lt;ext&amp;gt;&lt;/code&gt; per user&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Generating a unique filename per upload (e.g., timestamped) accumulates stale files in storage every time a user updates their photo. A fixed path with upsert means one file per user, always replaced. No cleanup job needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multer memory storage, not disk&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The server runs on Render — no persistent disk. Writing temp files would fail between requests anyway. Memory storage keeps the pipeline direct: file arrives → buffer → forward to Supabase → discard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rejection requires feedback, approval does not&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Approving content is a binary yes. Rejecting content without explanation leaves the submitter with no way to improve their submission. Feedback is required on rejection, enforced at both the API and UI layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Submissions open to all authenticated users&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Restricting submissions to admins would make the content team a permanent bottleneck. The &lt;code&gt;approval_status: "pending"&lt;/code&gt; flow handles gatekeeping — every submission goes through review before going live. The submission endpoint does not need to be the gatekeeper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-row tab system for admin queue&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first design had only type filters. But pending content was the only reachable state — approved and rejected items had no UI entry point. Status tabs were added as the primary row because status reflects the workflow (pending → decision), with type as secondary filtering. Approved content now has a home in the admin UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bugs I Encountered and Fixed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Admin dashboard showing empty — &lt;code&gt;data?.items&lt;/code&gt; vs &lt;code&gt;data&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The admin dashboard stats were showing zero counts and the queue was showing no pending items, even though the database had content.&lt;/p&gt;

&lt;p&gt;Root cause: the API returns &lt;code&gt;{ success, data: [...] }&lt;/code&gt;. The frontend was reading &lt;code&gt;data?.items&lt;/code&gt; — which is always &lt;code&gt;undefined&lt;/code&gt; because the array is at &lt;code&gt;data&lt;/code&gt;, not &lt;code&gt;data.items&lt;/code&gt;. One character fix, but it made the entire admin interface appear broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guide TOC scrolling to wrong section
&lt;/h3&gt;

&lt;p&gt;Clicking a guide's table of contents entry scrolled to the wrong section. The TOC was built from one data source, the body rendered from another — they had drifted out of sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Derive the TOC by mapping over the same &lt;code&gt;content&lt;/code&gt; array used to render the body. Single source of truth — the two can never diverge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multer field name mismatch
&lt;/h3&gt;

&lt;p&gt;Profile image uploads were silently failing. The frontend was sending the file as &lt;code&gt;profileImage&lt;/code&gt; (camelCase) but the multer config expected &lt;code&gt;profile_image&lt;/code&gt; (snake_case). The field name mismatch meant multer never found the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Aligned field names across frontend and middleware configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guide cards rendering with no description text
&lt;/h3&gt;

&lt;p&gt;Guide cards were showing a title but no text beneath. The frontend was reading &lt;code&gt;card.summary&lt;/code&gt;, the API was returning &lt;code&gt;card.description&lt;/code&gt;. A field name mismatch that only surfaces when placeholder data is replaced with real API responses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sprint Reflections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What worked well&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The team is moving faster than ever. Issues that took two or three days in Sprint 2 are turning around in under 24 hours. The shared patterns — skeleton loading, the &lt;code&gt;Dropdown&lt;/code&gt; component, the response envelope, the &lt;code&gt;adminGuard&lt;/code&gt; — mean contributors spend less time on decisions and more time building.&lt;/p&gt;

&lt;p&gt;Closing issue #80 this sprint felt like a milestone. It had been open since Sprint 2 and tracked a wide surface area of unfinished API wiring. The app is now fully connected end to end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would do differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PR #136 bundled too much — submissions form, resources wiring, admin work, and a clean code pass all in one. It made review harder and history less readable. Going forward I want each PR to have a single concern, even if that means opening more of them.&lt;/p&gt;

&lt;p&gt;I also want to define API contracts — agreed field names, response shapes — before assigning frontend issues. The &lt;code&gt;card.summary&lt;/code&gt; vs &lt;code&gt;card.description&lt;/code&gt; mismatch and the &lt;code&gt;data?.items&lt;/code&gt; bug both happened because frontend and backend were built independently without a shared spec.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Most Proud Of
&lt;/h2&gt;

&lt;p&gt;The admin content management system. It is not flashy but it is the piece that makes the platform viable. Without it, submitted content just sits in the database with no way for the team to act on it. With it, the full lifecycle works: a user submits an opportunity → it lands in the review queue → an admin approves or rejects it with feedback → approved content goes live → if needed, it can be deleted. Every step is covered, audited, and protected by role-based access control.&lt;/p&gt;

&lt;p&gt;That is the infrastructure that makes everything else on the platform trustworthy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Team
&lt;/h2&gt;

&lt;p&gt;Another strong sprint from the team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marion&lt;/strong&gt; (&lt;a href="https://github.com/MarionBraide" rel="noopener noreferrer"&gt;@MarionBraide&lt;/a&gt;) — Training detail page, card rendering bug fix&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Victor&lt;/strong&gt; (&lt;a href="https://github.com/Okoukoni-Victor" rel="noopener noreferrer"&gt;@Okoukoni-Victor&lt;/a&gt;) — Guides section on resources page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Florence&lt;/strong&gt; (&lt;a href="https://github.com/Florence-code-hub" rel="noopener noreferrer"&gt;@Florence-code-hub&lt;/a&gt;) — Templates section on resources page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oluchi&lt;/strong&gt; (&lt;a href="https://github.com/luchiiii" rel="noopener noreferrer"&gt;@luchiiii&lt;/a&gt;) — Guide detail page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Esther&lt;/strong&gt; (&lt;a href="https://github.com/first-afk" rel="noopener noreferrer"&gt;@first-afk&lt;/a&gt;) — Template detail page, hero section title fix&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iyobosa&lt;/strong&gt; (&lt;a href="https://github.com/Fhave" rel="noopener noreferrer"&gt;@Fhave&lt;/a&gt;) — Training page filter fix, empty state messaging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gabriel&lt;/strong&gt; (&lt;a href="https://github.com/GabrielAbubakar" rel="noopener noreferrer"&gt;@GabrielAbubakar&lt;/a&gt;) — Loading skeletons for opportunity and training card lists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GitHub repository:&lt;/strong&gt; &lt;a href="https://github.com/Tabi-Project/Ekehi" rel="noopener noreferrer"&gt;github.com/Tabi-Project/Ekehi&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>womenintech</category>
    </item>
    <item>
      <title>Ekehi Engineering Sprint 3 — Detail Pages, Save States, and Leading a Sprint</title>
      <dc:creator>AJ</dc:creator>
      <pubDate>Sat, 21 Mar 2026 09:04:49 +0000</pubDate>
      <link>https://forem.com/aj1732/ekehi-engineering-sprint-3-detail-pages-save-states-and-leading-a-sprint-bgp</link>
      <guid>https://forem.com/aj1732/ekehi-engineering-sprint-3-detail-pages-save-states-and-leading-a-sprint-bgp</guid>
      <description>&lt;p&gt;&lt;em&gt;This is part of an ongoing series documenting the build of **Ekehi&lt;/em&gt;&lt;em&gt;, a platform connecting African entrepreneurs with funding opportunities, training programmes, and business resources. I'm AJ, the Engineering Lead on the project.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Previously on Ekehi Engineering — What We're Building
&lt;/h2&gt;

&lt;p&gt;Ekehi is a full-stack web app built with &lt;strong&gt;vanilla JavaScript on the frontend (Netlify)&lt;/strong&gt;, an &lt;strong&gt;Express.js API on Render&lt;/strong&gt;, and &lt;strong&gt;Supabase (PostgreSQL + Auth)&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Personally Shipped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Note Again&lt;/strong&gt;: Going through the &lt;a href="https://github.com/Tabi-Project/Ekehi/tree/development" rel="noopener noreferrer"&gt;repository&lt;/a&gt; while reading the article will provide more context &lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fixed the Broken Filter and Search Wiring (PR #83)
&lt;/h3&gt;

&lt;p&gt;Coming out of Sprint 2, the opportunities and resources pages had a filtering UI — dropdowns, search bars, the works — but &lt;strong&gt;none of it was actually working&lt;/strong&gt;. Filter selections were updating local state but never being sent to the API. Users could interact with every control and nothing would happen.&lt;/p&gt;

&lt;p&gt;The fix involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building a shared &lt;code&gt;buildQueryString(filters)&lt;/code&gt; utility in &lt;code&gt;opportunity.utils.js&lt;/code&gt; that serialises filter state into URL query params&lt;/li&gt;
&lt;li&gt;Wiring &lt;code&gt;onFilterChange&lt;/code&gt; on both pages to call the API with the constructed query string&lt;/li&gt;
&lt;li&gt;Extracting a shared &lt;code&gt;formatDate&lt;/code&gt; utility to remove a duplicate &lt;code&gt;formatDeadline&lt;/code&gt; function that existed in three places&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Structural Backend and Client Updates (PR #97)
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scaffolding the admin dashboard (&lt;code&gt;/admin&lt;/code&gt;) with a content queue and review pages&lt;/li&gt;
&lt;li&gt;Wiring backend routes for content management (create, update, approve opportunities)&lt;/li&gt;
&lt;li&gt;Setting up the &lt;code&gt;requireRole&lt;/code&gt; middleware for role-based access control&lt;/li&gt;
&lt;li&gt;Creating the sprint 3 issue backlog on GitHub and assigning every task to the right team member&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Though we had to shift the use of the admin to next week, getting this started will make the load easier.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Opportunity Detail Page Redesign + Save Feature (PR #111)
&lt;/h3&gt;

&lt;p&gt;This was the biggest lift of the week and touched both the frontend and backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-column layout with sticky aside&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 👑):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#detail-root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="m"&gt;296px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--space-6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.detail-aside&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sticky&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--space-6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grid only reveals after the API response arrives — before that, a loading text spans the full width using &lt;code&gt;grid-column: 1 / -1&lt;/code&gt;. Once data loads, &lt;code&gt;root.classList.add("is-loaded")&lt;/code&gt; triggers a CSS rule that unhides both columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eligibility criteria as a bullet list&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The eligibility criteria came back from the API as a single long string. Rather than dump it into a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tag, it was split on periods and each sentence rendered as a &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderEligibilityList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;ul class="eligibility-list"&amp;gt;
    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;li&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/li&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
  &amp;lt;/ul&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Correct save state on page load&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;GET /opportunities/:id&lt;/code&gt;) was a public, unauthenticated endpoint, so &lt;code&gt;req.user&lt;/code&gt; was always &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My solution: add an &lt;code&gt;optionalAuth&lt;/code&gt; middleware that reads the JWT if present but never blocks the request if it's missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;optionalAuth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;req.user&lt;/code&gt; now optionally populated, I updated &lt;code&gt;getOpportunityById&lt;/code&gt; in the service layer to accept a &lt;code&gt;userId&lt;/code&gt; and run a secondary query against &lt;code&gt;saved_opportunities&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getOpportunityById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;funding_opportunities&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approval_status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;is_saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saved_opportunities&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opportunity_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opportunity_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maybeSingle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;is_saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;is_saved&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend now reads &lt;code&gt;opp.is_saved&lt;/code&gt; directly from the detail response — no second API call, no loading flicker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contributor page skeleton loading&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;When real data arrives, &lt;code&gt;container.innerHTML = ""&lt;/code&gt; clears the skeletons and real cards are appended.&lt;/p&gt;




&lt;h2&gt;
  
  
  Technical Decisions and Why
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;optionalAuth&lt;/code&gt; over a separate saved-state endpoint&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first instinct was to add a &lt;code&gt;GET /opportunities/:id/save&lt;/code&gt; 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 &lt;code&gt;is_saved&lt;/code&gt; in the detail response costs one lightweight DB query only when the user is logged in, and zero overhead otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;style.display&lt;/code&gt; instead of the &lt;code&gt;hidden&lt;/code&gt; attribute&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tabs element on the opportunities page needed to be hidden for logged-out users. Setting &lt;code&gt;element.hidden = true&lt;/code&gt; wasn't working. The reason: a &lt;code&gt;.flex&lt;/code&gt; utility class in our stylesheet was setting &lt;code&gt;display: flex&lt;/code&gt; with higher specificity than the &lt;code&gt;[hidden]&lt;/code&gt; attribute selector. Using &lt;code&gt;element.style.display = "none"&lt;/code&gt; overrides both — inline styles always win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skeleton cards in static HTML, not JavaScript&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Breadcrumb outside &lt;code&gt;#detail-root&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The detail page renders by setting &lt;code&gt;innerHTML&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bugs I Encountered and Fixed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Modal appearing in the top-left corner
&lt;/h3&gt;

&lt;p&gt;After building the save auth-gate modal with the native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element, it was rendering in the top-left corner instead of centred. The native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; centres itself using &lt;code&gt;margin: auto&lt;/code&gt; — but our global CSS reset included &lt;code&gt;* { margin: 0 }&lt;/code&gt;, which zeroed it out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Explicitly position the modal with CSS transforms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.modal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;-50%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Modal closing when clicking inside the content
&lt;/h3&gt;

&lt;p&gt;The backdrop click listener was using &lt;code&gt;e.target === dialog&lt;/code&gt;, which fires for both a click on the backdrop &lt;em&gt;and&lt;/em&gt; a click on the dialog's own padding. So clicking anywhere near the edge of the modal content was dismissing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Use &lt;code&gt;getBoundingClientRect&lt;/code&gt; to check whether the click coordinates are actually outside the dialog box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isBackdrop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isBackdrop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;is_saved&lt;/code&gt; always returning &lt;code&gt;false&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;After adding &lt;code&gt;is_saved&lt;/code&gt; to the service, the save button on the detail page was still showing "Save" even for saved opportunities. The middleware chain was correct but &lt;code&gt;req.user&lt;/code&gt; was always &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Root cause: the route &lt;code&gt;GET /:id&lt;/code&gt; had no auth middleware at all — &lt;code&gt;req.user&lt;/code&gt; was never set. The fix was adding &lt;code&gt;optionalAuth&lt;/code&gt; to the route, which only runs the Supabase token check when an &lt;code&gt;Authorization&lt;/code&gt; header is actually present.&lt;/p&gt;

&lt;h3&gt;
  
  
  Saved tab count always showing zero on page load
&lt;/h3&gt;

&lt;p&gt;When a logged-in user landed on the opportunities page, the "Saved" tab badge showed &lt;code&gt;0&lt;/code&gt; even if they had saved items. The count was only updated after they switched to the tab and triggered a fetch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Fire a prefetch request on page load using &lt;code&gt;?limit=1&lt;/code&gt; to get the total count from pagination metadata, without fetching all the actual records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prefetchSavedCount&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/opportunities/saved?limit=1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;updateSavedTabCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I Coordinated as Engineering Lead
&lt;/h2&gt;

&lt;p&gt;Beyond my own code, I created and assigned &lt;strong&gt;12 issues&lt;/strong&gt; at the start of the week that the full team executed on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Assignee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#84&lt;/td&gt;
&lt;td&gt;Navigation update — Submissions link + Post a Job button&lt;/td&gt;
&lt;td&gt;Florence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#85&lt;/td&gt;
&lt;td&gt;Hero section full redesign&lt;/td&gt;
&lt;td&gt;Esther&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#86&lt;/td&gt;
&lt;td&gt;About section — dark purple full-width banner&lt;/td&gt;
&lt;td&gt;Marion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#87&lt;/td&gt;
&lt;td&gt;Value proposition — full-width image with colour wash&lt;/td&gt;
&lt;td&gt;Victor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#88&lt;/td&gt;
&lt;td&gt;What-we-offer — interactive list with contextual image&lt;/td&gt;
&lt;td&gt;Oluchi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#89&lt;/td&gt;
&lt;td&gt;FAQ section&lt;/td&gt;
&lt;td&gt;Iyobosa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#92&lt;/td&gt;
&lt;td&gt;Signup step 1 — multi-step identity form&lt;/td&gt;
&lt;td&gt;Marion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#93&lt;/td&gt;
&lt;td&gt;Signup step 2 — password creation&lt;/td&gt;
&lt;td&gt;Marion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#94&lt;/td&gt;
&lt;td&gt;Login page redesign&lt;/td&gt;
&lt;td&gt;Florence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#90&lt;/td&gt;
&lt;td&gt;Opportunity detail page redesign&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#91&lt;/td&gt;
&lt;td&gt;Save auth-gate modal&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#96&lt;/td&gt;
&lt;td&gt;Backend bookmark feature&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 12 issues were closed by end of week.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sprint Reflections
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What worked well&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would do differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My own commit messages were too vague. "chore: update opportunities detail page" tells you nothing about &lt;em&gt;why&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;I also want to define API contracts (request/response shape) before frontend work begins. This sprint, the &lt;code&gt;is_saved&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Most Proud Of
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;optionalAuth&lt;/code&gt; 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 &lt;code&gt;is_saved: false&lt;/code&gt;, 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Team
&lt;/h2&gt;

&lt;p&gt;Big shoutout to the team for executing an ambitious sprint cleanly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Marion&lt;/strong&gt; (&lt;a href="https://github.com/MarionBraide" rel="noopener noreferrer"&gt;@MarionBraide&lt;/a&gt;) — About section, multi-step signup flow, bug fixes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Esther&lt;/strong&gt; (&lt;a href="https://github.com/first-afk" rel="noopener noreferrer"&gt;@first-afk&lt;/a&gt;) — Hero section redesign&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Victor&lt;/strong&gt; (&lt;a href="https://github.com/Okoukoni-Victor" rel="noopener noreferrer"&gt;@Okoukoni-Victor&lt;/a&gt;) — Value proposition section&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oluchi&lt;/strong&gt; (&lt;a href="https://github.com/luchiiii" rel="noopener noreferrer"&gt;@luchiiii&lt;/a&gt;) — What-we-offer section&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Iyobosa&lt;/strong&gt; (&lt;a href="https://github.com/Fhave" rel="noopener noreferrer"&gt;@Fhave&lt;/a&gt;) — FAQ section&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Florence&lt;/strong&gt; (&lt;a href="https://github.com/Florence-code-hub" rel="noopener noreferrer"&gt;@Florence-code-hub&lt;/a&gt;) — Navigation update, login page redesign&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GitHub repository:&lt;/strong&gt; &lt;a href="https://github.com/Tabi-Project/Ekehi" rel="noopener noreferrer"&gt;github.com/Tabi-Project/Ekehi&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thought
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                                                                                                      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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>womenintech</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Ekehi Engineering Sprint 2 — Building the Ekehi Resource Discovery Engine</title>
      <dc:creator>AJ</dc:creator>
      <pubDate>Sat, 14 Mar 2026 10:04:12 +0000</pubDate>
      <link>https://forem.com/aj1732/ekehi-engineering-sprint-2-building-the-ekehi-resource-discovery-engine-5bbe</link>
      <guid>https://forem.com/aj1732/ekehi-engineering-sprint-2-building-the-ekehi-resource-discovery-engine-5bbe</guid>
      <description>&lt;p&gt;&lt;em&gt;How I architected and shipped features of a funding discovery platform for African women entrepreneurs, orchestrating a team of 7 while staying hands-on in the codebase.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;As stated last week in my &lt;a href="https://dev.to/aj1732/how-we-built-ekehi-engineering-a-womens-business-intelligence-platform-in-one-sprint-5dmo"&gt;previous article&lt;/a&gt;, Ekehi is a resource discovery platform built for women-led businesses across Africa. It surfaces funding opportunities, training programmes, and credit products which are aggregated, vetted, and filterable in one place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Going through the &lt;a href="https://github.com/Tabi-Project/Ekehi/tree/development" rel="noopener noreferrer"&gt;repository&lt;/a&gt; while reading the article will provide more context &lt;/p&gt;

&lt;p&gt;As the Engineering Lead, the team's job was to ship the three core features that would make Ekehi real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feature 3.1&lt;/strong&gt; — Funding Opportunities: a searchable, filterable directory of active funding across VC, grants, accelerators, loans, and more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature 3.3&lt;/strong&gt; — Training &amp;amp; Capacity Building: a curated listing of business programmes, bootcamps, and accelerators for women entrepreneurs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature 3.4&lt;/strong&gt; — Sector Classification: a consistent taxonomy enabling precise filtering across all resource types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven frontend contributors. A backend to build from scratch. One week.&lt;/p&gt;




&lt;h2&gt;
  
  
  Designing the Architecture First
&lt;/h2&gt;

&lt;p&gt;Before writing a line of feature code, I had to answer one question: &lt;em&gt;where does the data live, and how does the frontend get it?&lt;/em&gt; (this is two grouped into one, but you get the gist).&lt;/p&gt;

&lt;p&gt;The stack constraint was already set — Netlify for the frontend, Supabase as the database. We had to introduce an Node.js/Express API layer on Render between them, rather than letting the frontend call Supabase directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client (Netlify) → Node.js/Express API (Render) → Supabase (PostgreSQL + Auth)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was deliberate. Calling Supabase directly from the frontend would have required exposing an API key in client-side JS — and even with RLS, that creates a surface area I didn't want. The Express server holds the service role key in an environment variable, never exposed to the client. The server became the single security boundary.&lt;/p&gt;

&lt;p&gt;The tradeoff is an extra network hop. For this use case, which is mostly read operations on a discovery tool, it was the right call.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Security Model
&lt;/h3&gt;

&lt;p&gt;Supabase's Row Level Security is enabled on all tables, but the server bypasses it using the service role key. This might seem backwards — why enable RLS if you bypass it? The answer is defence in depth. RLS is a safety net in case something is misconfigured at the server layer. The real gate is the Node.js/Express server, which hardcodes &lt;code&gt;approval_status = 'approved'&lt;/code&gt; into every list query. No matter what query params the frontend sends, unapproved records are never reachable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layered Architecture
&lt;/h3&gt;

&lt;p&gt;The backend was structured as a strict four-layer system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Route → Controller → Service → Supabase SDK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A controller never touches the database. A service never touches &lt;code&gt;req&lt;/code&gt; or &lt;code&gt;res&lt;/code&gt;. This isn't just clean code preference — it makes each layer independently replaceable and testable. When a Supabase query needed changing, I touched only the service file. When a response format needed updating, only the controller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the Free-Tier Server Alive
&lt;/h3&gt;

&lt;p&gt;Render's free tier spins down after 15 minutes of inactivity — which would mean a 30-second cold start for the first user every morning. I set up a cron job to ping the meta endpoint every 15 minutes, keeping the instance warm. Small operational detail, significant user experience impact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Data Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Filter-as-Query-Builder Pattern
&lt;/h3&gt;

&lt;p&gt;Feature 3.1 and 3.3 both required multi-dimensional filtering. Rather than building raw SQL strings or a complex query DSL, I applied each filter conditionally to a Supabase query object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;funding_opportunities&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIELDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;exact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approval_status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// always applied — not a client param&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`opportunity_title.ilike.%&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%,...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sectors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sector&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;{ count: 'exact' }&lt;/code&gt; returns the total row count alongside the data in a single query — no second round-trip needed for pagination metadata. Every list endpoint returns a consistent &lt;code&gt;meta&lt;/code&gt; object: &lt;code&gt;{ page, limit, total, totalPages, hasNextPage, hasPrevPage }&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature 3.4 — Sector Taxonomy as a First-Class Design Decision
&lt;/h3&gt;

&lt;p&gt;Sector classification isn't glamorous, but getting it wrong cascades into every filter in the system. I designed the taxonomy as enum slugs — &lt;code&gt;agriculture_food&lt;/code&gt;, &lt;code&gt;technology_digital&lt;/code&gt;, &lt;code&gt;fashion_textiles&lt;/code&gt; — stored as arrays on each record. This meant one opportunity could span multiple sectors (a common real-world case), and filtering used Supabase's &lt;code&gt;contains()&lt;/code&gt; operator against the array.&lt;/p&gt;

&lt;p&gt;I also built a &lt;code&gt;/meta&lt;/code&gt; endpoint that returns all enum values — opportunity types, sectors, stages, cost types, duration ranges — in a single call. Frontend components populate their dropdowns from this rather than having hardcoded option lists scattered across multiple files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Consistent API Contract
&lt;/h3&gt;

&lt;p&gt;Every endpoint — success or error — returns the same envelope shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I documented every endpoint in &lt;code&gt;endpoints.md&lt;/code&gt; with request/response examples. This wasn't just good practice — with 7 contributors building frontend integrations, a shared reference prevented mismatched field names and assumptions about response shapes from becoming bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Frontend Foundation for a Team of 7
&lt;/h2&gt;

&lt;p&gt;Before features could be built, the frontend needed an architecture that 7 contributors could work within without constant coordination. I approached this in three layers: a component library, a module system, and a database schema that wouldn't break filtering.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Component Library
&lt;/h3&gt;

&lt;p&gt;Rather than leaving each contributor to build UI primitives from scratch — and ending up with 7 different button styles — I built a shared component library under &lt;code&gt;client/shared/components/&lt;/code&gt;, each component following the same &lt;strong&gt;static factory pattern&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Apply now&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every component has one public method, &lt;code&gt;create()&lt;/code&gt;, that returns a DOM element. Internal rendering logic is hidden behind ES2022 private class fields (&lt;code&gt;#buildClasses()&lt;/code&gt;, &lt;code&gt;#buildHTML()&lt;/code&gt;, &lt;code&gt;#attachEventListeners()&lt;/code&gt;). Contributors couldn't accidentally break internals — the only surface they ever touched was the public API.&lt;/p&gt;

&lt;p&gt;The library covered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Button&lt;/strong&gt; — 4 variants (primary, secondary, outline, ghost), 3 sizes, icon support, renderable as &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; for link CTAs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input&lt;/strong&gt; — form input with validation states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropdown&lt;/strong&gt; — custom styled select with keyboard dismissal, click-outside-to-close, and &lt;code&gt;onChange&lt;/code&gt; callback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SearchBar&lt;/strong&gt; — input + search button, fires &lt;code&gt;onSearch&lt;/code&gt; on button click or Enter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nav&lt;/strong&gt; — self-mounting; drop &lt;code&gt;&amp;lt;nav id="nav-root"&amp;gt;&lt;/code&gt; anywhere and import the script, it renders itself. Handles mobile hamburger menu, active link detection, and authenticated vs unauthenticated CTA states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Footer&lt;/strong&gt; — same self-mounting pattern&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every component was documented in &lt;code&gt;docs/components/&lt;/code&gt; with a full API reference, usage examples, and instructions for extending it. The goal was that any contributor could pick up a component without asking me how it worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating to ES Modules
&lt;/h3&gt;

&lt;p&gt;Last sprint, every HTML page was loading 4–6 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags in a specific order — &lt;code&gt;api.js&lt;/code&gt; before &lt;code&gt;auth.service.js&lt;/code&gt; before the page script, or things broke silently. With 7 contributors adding pages, this was a maintenance problem waiting to happen.&lt;/p&gt;

&lt;p&gt;I migrated the entire client codebase to native ES modules. Every shared utility and component became an explicit &lt;code&gt;import&lt;/code&gt;. Every page went from a stack of script tags to a single:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"page.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;type="module"&lt;/code&gt; is automatically deferred — no load-order issues. ES modules are cached — &lt;code&gt;auth.service.js&lt;/code&gt; imported by both &lt;code&gt;nav.js&lt;/code&gt; and &lt;code&gt;login.js&lt;/code&gt; evaluates only once. Contributors could add a component to their page with a single import line, without touching HTML at all.&lt;/p&gt;

&lt;p&gt;A full migration plan was written in &lt;code&gt;docs/setup/es-modules-migration.md&lt;/code&gt; before executing it — mapping every file that needed changes, every new &lt;code&gt;import&lt;/code&gt;/&lt;code&gt;export&lt;/code&gt; statement, and every HTML page that needed its script tags collapsed. The migration was executed as a single PR (#75) to avoid a partial state where some pages used modules and others didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Database Refactor That Made Filtering Possible
&lt;/h3&gt;

&lt;p&gt;This was the most consequential piece of work in the sprint, and the least visible.&lt;/p&gt;

&lt;p&gt;When wiring the filter queries, I discovered the database schema would break filtering by design. Categorical fields like &lt;code&gt;sector&lt;/code&gt; and &lt;code&gt;stage_eligibility&lt;/code&gt; were stored as free-text &lt;code&gt;varchar&lt;/code&gt; — values like &lt;code&gt;"Technology &amp;amp; Digital Services, Financial Services &amp;amp; Fintech"&lt;/code&gt; comma-separated in a single column. A standard &lt;code&gt;.eq('sector', 'technology_digital')&lt;/code&gt; would never match.&lt;/p&gt;

&lt;p&gt;The schema was refactored from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL enums&lt;/strong&gt; for single-value categoricals (&lt;code&gt;opportunity_type&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;) — validation enforced at the database layer, not application code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;text[]&lt;/code&gt; arrays&lt;/strong&gt; for multi-value fields (&lt;code&gt;sectors&lt;/code&gt;, &lt;code&gt;stages&lt;/code&gt;) — a single opportunity can belong to multiple sectors, which is the real-world case&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIN indexes&lt;/strong&gt; on every array column — PostgreSQL's &lt;code&gt;@&amp;gt;&lt;/code&gt; operator with a GIN index turns a multi-sector filter into a fast indexed lookup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookup tables&lt;/strong&gt; (&lt;code&gt;sectors&lt;/code&gt;, &lt;code&gt;stages&lt;/code&gt;) as the canonical source of display names, decoupled from the enum slugs used in queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose &lt;code&gt;text[]&lt;/code&gt; arrays over junction tables deliberately. Supabase's JS SDK maps &lt;code&gt;.contains('sectors', ['technology_digital'])&lt;/code&gt; directly to PostgreSQL's &lt;code&gt;@&amp;gt;&lt;/code&gt; — one line, no JOINs, no raw SQL. Junction tables would have required &lt;code&gt;supabase.rpc()&lt;/code&gt; or nested filters that broke the existing service layer pattern.&lt;/p&gt;

&lt;p&gt;The migration ran as 8 sequential scripts, each documented with rollback considerations. The data mapping exercise — converting &lt;code&gt;"Grant-NGO"&lt;/code&gt; to &lt;code&gt;grant_ngo&lt;/code&gt;, &lt;code&gt;"Rolling Applications"&lt;/code&gt; to &lt;code&gt;rolling_applications&lt;/code&gt;, fixing edge cases where values were stored without spaces after commas, took as long as writing the migration code itself.&lt;/p&gt;

&lt;p&gt;The result: filtering just works. &lt;code&gt;.contains('sectors', [sector])&lt;/code&gt; against a GIN-indexed &lt;code&gt;text[]&lt;/code&gt; column is both correct and fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Documentation as a Force Multiplier
&lt;/h3&gt;

&lt;p&gt;With 7 contributors and little daily standup, documentation was how I kept the team unblocked. By the end of the sprint, the &lt;code&gt;docs/&lt;/code&gt; directory contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docs/components/&lt;/code&gt;&lt;/strong&gt; — full API reference for every shared component&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docs/api/endpoints.md&lt;/code&gt;&lt;/strong&gt; — every endpoint with request/response examples, all query params, all error codes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docs/setup/system-design-case-study.md&lt;/code&gt;&lt;/strong&gt; — the full architectural rationale, for onboarding and for the team's own understanding of what they were building on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docs/setup/es-modules-migration.md&lt;/code&gt;&lt;/strong&gt; — the migration plan before execution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docs/setup/db-refactor.md&lt;/code&gt;&lt;/strong&gt; — the schema refactor with every migration script, data mapping, and verification query documented&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A contributor building the training page filter section shouldn't need to ask me what the Dropdown API is, what query params the &lt;code&gt;/trainings&lt;/code&gt; endpoint accepts, or what slug values are valid for &lt;code&gt;programme_type&lt;/code&gt;. That information lived in the docs. The friction of building fell from "wait for the lead to answer" to "read the reference."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bug That Broke Everything After Login
&lt;/h2&gt;

&lt;p&gt;Midway through the sprint, I caught a subtle but critical bug: the opportunities page would load correctly for unauthenticated users, but return an empty array immediately after login.&lt;/p&gt;

&lt;p&gt;The root cause was a &lt;strong&gt;Supabase singleton contamination&lt;/strong&gt; bug. The &lt;code&gt;auth.service.js&lt;/code&gt; was calling &lt;code&gt;signInWithPassword()&lt;/code&gt; on the shared service role client — the same singleton used for all database queries. Even with &lt;code&gt;persistSession: false&lt;/code&gt;, the GoTrueClient stores the returned user JWT in memory as &lt;code&gt;currentSession&lt;/code&gt;. Every subsequent database query then sent &lt;code&gt;Authorization: Bearer &amp;lt;user_jwt&amp;gt;&lt;/code&gt; instead of the service role key, making PostgREST apply RLS. Since there's no permissive RLS policy for the &lt;code&gt;authenticated&lt;/code&gt; role, queries returned empty.&lt;/p&gt;

&lt;p&gt;The fix was architectural: a separate Supabase client initialised with the anon key, used exclusively for user-facing auth operations. The service role singleton is never touched by auth flows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// auth.service.js — separate client, never shared&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supabaseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;supabaseAnonKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoRefreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;persistSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of bug that's invisible in testing and devastating in production, because it only manifests after a user successfully logs in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Orchestrating the Team
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Breaking Features into Issues
&lt;/h3&gt;

&lt;p&gt;I decomposed each feature into discrete GitHub issues with explicit acceptance criteria and assigned them across the team. The filter section for opportunities, the training page UI, the login wiring, the signup wiring, the navbar auth state — each became a separate issue with clear inputs and outputs.&lt;/p&gt;

&lt;p&gt;Some contributors didn't complete their assignments before the sprint deadline. Rather than letting work stall, I reassigned and in several cases picked up the work myself.&lt;/p&gt;

&lt;h3&gt;
  
  
  PR Reviews — Holding the Bar
&lt;/h3&gt;

&lt;p&gt;I reviewed every PR that touched the three core features. Two patterns emerged in reviews that I pushed back on consistently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR #63 — Signup wiring:&lt;/strong&gt; Requested changes before approval. The initial implementation had issues with how the auth flow was handling the response from the server — needed corrections before merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PR #66 — Training &amp;amp; Resources filter section:&lt;/strong&gt; Requested changes before approval. The initial UI wiring wasn't aligned with the component API established.&lt;/p&gt;

&lt;p&gt;On both, the aim was on consistency with the patterns the rest of the codebase had already established. Inconsistency at the integration layer is what creates bugs that take hours to trace.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring the Frontend to the API
&lt;/h2&gt;

&lt;p&gt;Once the backend was live, I oversaw the integration work. Two issues surfaced during review:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared utilities extracted to prevent duplication.&lt;/strong&gt; Both pages needed the same date formatting and amount scaling logic. Rather than letting each page carry its own copy, I extracted &lt;code&gt;formatAmount&lt;/code&gt;, &lt;code&gt;formatDate&lt;/code&gt;, &lt;code&gt;daysUntil&lt;/code&gt;, &lt;code&gt;humanize&lt;/code&gt;, and &lt;code&gt;buildQueryString&lt;/code&gt; into a shared &lt;code&gt;opportunity.utils.js&lt;/code&gt; module — imported by both the listing and detail pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Intl.NumberFormat&lt;/code&gt; memoization.&lt;/strong&gt; The original amount formatter was constructing a new &lt;code&gt;Intl.NumberFormat&lt;/code&gt; instance on every card render. On a listing page with 20 results, that's 40 expensive constructor calls per page load. I added a &lt;code&gt;Map&lt;/code&gt;-based cache keyed by currency code, one construction per currency, reused on every subsequent call.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Shipped
&lt;/h2&gt;

&lt;p&gt;By end of sprint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A live Express API on Render serving Features 3.1 and 3.3, with full filter support, pagination, and a consistent response contract&lt;/li&gt;
&lt;li&gt;An opportunity detail page with full listing data, deadline countdown, sector/stage tags, and an apply CTA&lt;/li&gt;
&lt;li&gt;Filter and search wired end-to-end on both the opportunities and resources pages&lt;/li&gt;
&lt;li&gt;A shared sector taxonomy (Feature 3.4) implemented as enum slugs across the database, API, and frontend filter components&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;/meta&lt;/code&gt; endpoint returning all filter enum values for dynamic dropdown population&lt;/li&gt;
&lt;li&gt;Auth flow (signup, login, logout) wired across the frontend, with a critical singleton bug patched in the backend&lt;/li&gt;
&lt;li&gt;PRs reviewed, 2 with requested changes before merge&lt;/li&gt;
&lt;li&gt;Endpoint documentation and system design case study written for the team&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;The filter state on both pages is duplicated — the same &lt;code&gt;filters&lt;/code&gt; object shape, the same &lt;code&gt;onFilterChange&lt;/code&gt; pattern, the same &lt;code&gt;buildQueryString&lt;/code&gt; call. With more time I would extract a shared &lt;code&gt;FilteredPage&lt;/code&gt; module that both pages compose from, rather than each carrying their own copy of the pattern. It works now. It will diverge later.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/meta&lt;/code&gt; endpoint also isn't being consumed by the frontend yet — filter options are still hardcoded in the JS files. The infrastructure is there; it just needs to be wired in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thought
&lt;/h2&gt;

&lt;p&gt;The most important thing I did this sprint wasn't writing code, it was actually making decisions early enough that the team could move in parallel without stepping on each other. The layered backend architecture, the response envelope, the sector taxonomy, the component API — these were the guardrails that let 7 people build towards the same system without needing a daily sync to stay aligned.&lt;/p&gt;

&lt;p&gt;Engineering leadership at this scale is mostly about removing ambiguity before it becomes a bug.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>designsystem</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How We Built Ekehi: Engineering a Women's Business Intelligence Platform in One Sprint</title>
      <dc:creator>AJ</dc:creator>
      <pubDate>Sat, 07 Mar 2026 09:18:03 +0000</pubDate>
      <link>https://forem.com/aj1732/how-we-built-ekehi-engineering-a-womens-business-intelligence-platform-in-one-sprint-5dmo</link>
      <guid>https://forem.com/aj1732/how-we-built-ekehi-engineering-a-womens-business-intelligence-platform-in-one-sprint-5dmo</guid>
      <description>&lt;p&gt;I want to tell you about one of the most satisfying weeks I've had as an engineer.&lt;/p&gt;

&lt;p&gt;Okay, maybe the title was click bait, since we only built the landing page, but it is still a platform, nevertheless.&lt;/p&gt;

&lt;p&gt;Twelve contributors. Three tracks. One sprint. Zero frameworks. A fully structured, token-based, responsive landing page — shipped for International Women's Day 2026.&lt;/p&gt;

&lt;p&gt;This is the engineering story of &lt;strong&gt;Ekehi&lt;/strong&gt; — what we built, how we coordinated it across three tracks (frontend, UI/UX, and backend), the architectural decisions I made as Engineering Lead, and what I learned from running a synchronised multi-track team build at this pace.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Ekehi?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ekehi&lt;/strong&gt; is a business intelligence platform built for women entrepreneurs and women-led SMEs across Nigeria and Africa. It aggregates funding opportunities — grants, VCs, loans, government schemes, accelerators — alongside training programmes, mentorship networks, and business resources into a single searchable, filterable hub.&lt;/p&gt;

&lt;p&gt;The platform was created as part of the &lt;strong&gt;Tabî Project by TEE Foundation&lt;/strong&gt; for International Women's Day 2026.&lt;/p&gt;

&lt;p&gt;The problem it solves is real: women-owned businesses across Africa experience what researchers call a &lt;em&gt;"triple penalty"&lt;/em&gt; — limited access to capital, fewer connections to formal business networks, and low visibility in male-dominated investment ecosystems. When funding opportunities do exist, most women never hear about them. Ekehi fixes the information gap.&lt;/p&gt;

&lt;p&gt;The tech stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;HTML, CSS, Vanilla JavaScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Node.js + Express&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Supabase (PostgreSQL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Supaash Auth + JWT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Netlify (frontend), Railway (backend)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No React. No Vue. No build step. Deliberate choices — all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Team
&lt;/h2&gt;

&lt;p&gt;Ekehi was built across three tracks working in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frontend Track&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contributor&lt;/th&gt;
&lt;th&gt;GitHub&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AJ (Engineering Lead)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/AJ1732" rel="noopener noreferrer"&gt;@AJ1732&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marion Braide&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/MarionBraide" rel="noopener noreferrer"&gt;@MarionBraide&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Florence&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/Florence-code-hub" rel="noopener noreferrer"&gt;@Florence-code-hub&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Okwuosa Oluchi&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/luchiiii" rel="noopener noreferrer"&gt;@luchiiii&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Victor Okoukoni&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/Okoukoni-Victor" rel="noopener noreferrer"&gt;@Okoukoni-Victor&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Esther Orieji&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/first-afk" rel="noopener noreferrer"&gt;@first-afk&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pheonixai&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/Pheonixai" rel="noopener noreferrer"&gt;@Pheonixai&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;UI/UX Track&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contributor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Michael Babajide Boluwatife&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fisayo Rotibi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Osuji Wisdom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Backend Track&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contributor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Olusegun Adeleke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sodiq Semiu&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;QA&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contributor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gabriel Abubakar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The frontend sprint described in this article ran concurrently with the UI/UX team producing the Figma designs and the backend team architecting the API and database layer. Everything you see on the landing page was built directly from those Figma deliverables — not interpreted, not approximated. Pixel-faithful implementation was the standard.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Role: Engineering Lead
&lt;/h2&gt;

&lt;p&gt;I wore a lot of hats on this project. Before a single line of product code was written, my job was to ensure every contributor could move fast, independently, and without stepping on each other.&lt;/p&gt;

&lt;p&gt;That meant three things: &lt;strong&gt;architecture&lt;/strong&gt;, &lt;strong&gt;tooling&lt;/strong&gt;, and &lt;strong&gt;issue writing&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Monorepo Architecture (PR #2)
&lt;/h3&gt;

&lt;p&gt;The first thing I merged was the project skeleton — before anyone else had touched &lt;code&gt;index.html&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ekehi/
├── client/
│   ├── pages/          # HTML files
│   ├── css/
│   │   ├── base/       # Reset, tokens, typography
│   │   ├── components/ # Reusable component styles
│   │   └── pages/      # Page-level styles
│   ├── js/
│   │   ├── api/        # Fetch wrappers
│   │   ├── components/ # UI interaction logic
│   │   ├── pages/      # Page entry scripts
│   │   └── utils/      # Shared helpers
│   └── assets/
└── server/
    └── src/            # MVC: routes, controllers, models, middleware, services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also added the full contributor tooling before any work started:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CONTRIBUTING.md&lt;/code&gt; covering both frontend and backend workflows&lt;/li&gt;
&lt;li&gt;Three GitHub issue templates (Bug Report, Feature Request, Task)&lt;/li&gt;
&lt;li&gt;A PR template enforcing screenshots, linked issues, and a consistent checklist&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.gitignore&lt;/code&gt; covering &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;node_modules&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;server/.env.example&lt;/code&gt; documenting every required environment variable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle here is simple: &lt;strong&gt;a team that can't onboard fast, can't ship fast&lt;/strong&gt;. Every hour saved on "where does this file go?" is an hour spent building.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The Design System (PRs #26 + #31)
&lt;/h3&gt;

&lt;p&gt;This is the work I'm most proud of on this project.&lt;/p&gt;

&lt;p&gt;Before the team styled a single element, I converted the Figma design system into a complete &lt;strong&gt;CSS-native token system&lt;/strong&gt; — no Sass, no PostCSS, no JS-in-CSS. Pure custom properties.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* Color primitives */&lt;/span&gt;
  &lt;span class="py"&gt;--color-purple-800&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4c0066&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-purple-600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#9900cc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-neutral-950&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#131213&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c"&gt;/* Semantic aliases */&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-purple-800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-purple-600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-text-on-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-white&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-bg-base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-white&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c"&gt;/* Type scale */&lt;/span&gt;
  &lt;span class="py"&gt;--font-size-base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-size-base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c"&gt;/* 12px */&lt;/span&gt;
  &lt;span class="py"&gt;--text-sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-size-base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.875&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* 14px */&lt;/span&gt;
  &lt;span class="py"&gt;--text-base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-size-base&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c"&gt;/* 16px */&lt;/span&gt;
  &lt;span class="py"&gt;--text-5xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-size-base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c"&gt;/* 48px */&lt;/span&gt;

  &lt;span class="c"&gt;/* Spacing */&lt;/span&gt;
  &lt;span class="py"&gt;--spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--space-4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--spacing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c"&gt;/* 16px */&lt;/span&gt;
  &lt;span class="py"&gt;--space-8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--spacing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c"&gt;/* 32px */&lt;/span&gt;
  &lt;span class="py"&gt;--space-16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--spacing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* 64px */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two layers — &lt;strong&gt;primitives&lt;/strong&gt; and &lt;strong&gt;semantic aliases&lt;/strong&gt;. The primitives (&lt;code&gt;--color-purple-800&lt;/code&gt;) give you the raw value. The aliases (&lt;code&gt;--color-primary&lt;/code&gt;) give you the intent. This separation means you can retheme the entire product by changing three or four semantic aliases without touching a single component.&lt;/p&gt;

&lt;p&gt;The rule I enforced for every contributor: &lt;strong&gt;no hardcoded values, anywhere&lt;/strong&gt;. No &lt;code&gt;#4c0066&lt;/code&gt; in a component file. No &lt;code&gt;font-family: 'Urbanist'&lt;/code&gt; in a section stylesheet. Every value references a token.&lt;/p&gt;

&lt;p&gt;I also built the &lt;strong&gt;button component&lt;/strong&gt; — the first real piece of UI. It uses local CSS custom properties to create a self-contained state machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.btn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--btn-bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--btn-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-text-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--btn-bg-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--btn-color-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-text-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--btn-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--btn-color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;background-color&lt;/span&gt; &lt;span class="m"&gt;150ms&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="m"&gt;150ms&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.btn&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--btn-bg-hover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--btn-color-hover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.btn--primary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--btn-bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--btn-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-white&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--btn-bg-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-primary-hover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--btn-color-hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-white&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The modifier (&lt;code&gt;--primary&lt;/code&gt;) only sets local properties. It never writes &lt;code&gt;background-color&lt;/code&gt; or &lt;code&gt;color&lt;/code&gt; directly. The base &lt;code&gt;.btn&lt;/code&gt; rule is the only rule that does. This means specificity stays flat — modifiers can't fight each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One file per section&lt;/strong&gt; was the other structural decision I made in PR #31. Every BEM block lives in its own scoped file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;css/pages/landing/
├── nav.css
├── hero.css
├── about.css
├── value-proposition.css
├── mission.css
├── what-we-offer.css
├── cta.css
└── footer.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;landing.css&lt;/code&gt; imports them in document order. The result: six developers styling six different sections simultaneously with zero merge conflicts on CSS files.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Issue Writing as Engineering Work
&lt;/h3&gt;

&lt;p&gt;This part often goes undocumented. I want to talk about it.&lt;/p&gt;

&lt;p&gt;Every issue I wrote before the sprint started had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A screenshot from Figma showing exactly what to build&lt;/li&gt;
&lt;li&gt;A full content specification (copy, heading levels, BEM class names)&lt;/li&gt;
&lt;li&gt;An explicit list of dependencies (which issue must merge first)&lt;/li&gt;
&lt;li&gt;A numbered acceptance criteria checklist&lt;/li&gt;
&lt;li&gt;Implementation notes covering tricky edge cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is an excerpt from the mobile nav toggle issue (#38):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;On outside click:&lt;/strong&gt;&lt;br&gt;
If the menu is open and the user clicks anywhere outside &lt;code&gt;.nav__inner&lt;/code&gt;, close the menu and reset all states.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On window resize:&lt;/strong&gt;&lt;br&gt;
If the viewport width exceeds the mobile breakpoint and the menu is open, close the menu and reset all states (prevents a stuck-open menu if the user resizes from mobile to desktop).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implementation Notes:&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;aria-expanded&lt;/code&gt; as the single source of truth for open/closed state. Mobile breakpoint for the resize listener should match the CSS breakpoint — extract it as a named constant at the top of the script (&lt;code&gt;const MOBILE_BREAKPOINT = 768&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not a vague ticket — it's a spec. The developer assigned to it, &lt;a href="https://github.com/MarionBraide" rel="noopener noreferrer"&gt;@MarionBraide&lt;/a&gt;, shipped the implementation in a single PR with every acceptance criterion met.&lt;/p&gt;

&lt;p&gt;Well-written issues are one of the highest-leverage activities an engineering lead can do. They eliminate back-and-forth, reduce PR revision cycles, and let contributors focus on writing code instead of inferring intent.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Contributors Page (PR #44)
&lt;/h3&gt;

&lt;p&gt;My final individual contribution was the contributors page — HTML structure, JavaScript renderer, and full CSS.&lt;/p&gt;

&lt;p&gt;The architectural choice I want to highlight here is the &lt;strong&gt;JS rendering approach&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contributors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AJ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Engineering Lead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../assets/images/contributors/aj.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;imageStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;objectPosition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top center&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderContributors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;containerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fragment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocumentFragment&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;contributor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contributor-card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...build card elements...&lt;/span&gt;
    &lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DOMContentLoaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderContributors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contributors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contributors-grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth noting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DocumentFragment&lt;/code&gt; batches DOM writes&lt;/strong&gt; — one reflow instead of N. For a grid of cards, this matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;createElement&lt;/code&gt; over &lt;code&gt;innerHTML&lt;/code&gt;&lt;/strong&gt; — XSS-safe by construction. No string interpolation, no injection surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding a contributor requires only a new data entry&lt;/strong&gt; — no HTML changes, no render function edits. The &lt;code&gt;imageStyle&lt;/code&gt; field lets each contributor's photo be positioned correctly without touching CSS.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  How We Coordinated Seven People in One Sprint
&lt;/h2&gt;

&lt;p&gt;The team was fully distributed. We had no standup calls during the build phase. What we had instead was process.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Issues as the Source of Truth
&lt;/h3&gt;

&lt;p&gt;Every task existed as a GitHub Issue before work started. Contributors picked up issues, created branches named after them, and opened PRs that closed the linked issue with &lt;code&gt;Closes #N&lt;/code&gt;. This kept the board clean and made progress visible in real time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dual Workflow: ClickUp + GitHub
&lt;/h3&gt;

&lt;p&gt;Internal contributors tracked tasks in ClickUp (moving cards from In Progress to Done as PRs merged). External contributors worked entirely through GitHub Issues. Both systems pointed at the same work, so nothing fell through the gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  PR Template Enforcement
&lt;/h3&gt;

&lt;p&gt;Every PR had to include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A description of what changed and why&lt;/li&gt;
&lt;li&gt;Screenshots for any UI change&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Closes #N&lt;/code&gt; linking the issue&lt;/li&gt;
&lt;li&gt;A checklist: branch up to date, no &lt;code&gt;.env&lt;/code&gt; committed, mobile tested&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PRs without all of this were held for revision. This sounds strict — and it is. It also means I never had to ask "what does this PR do?" when reviewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Branching Strategy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;main
└── development  ← all PRs target here
    ├── feature/*
    ├── fix/*
    └── chore/*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No one committed to &lt;code&gt;main&lt;/code&gt; or &lt;code&gt;development&lt;/code&gt; directly. Every piece of work lived on a branch. This protected the integration branch and meant we could always roll back a section without touching others.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Team Shipped
&lt;/h2&gt;

&lt;p&gt;In the order they merged:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;Contributor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#2&lt;/td&gt;
&lt;td&gt;Monorepo architecture + contributor tooling&lt;/td&gt;
&lt;td&gt;&lt;a class="mentioned-user" href="https://dev.to/aj1732"&gt;@aj1732&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#26&lt;/td&gt;
&lt;td&gt;Design system — CSS tokens + utilities&lt;/td&gt;
&lt;td&gt;&lt;a class="mentioned-user" href="https://dev.to/aj1732"&gt;@aj1732&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#31&lt;/td&gt;
&lt;td&gt;Button component + section CSS scaffold&lt;/td&gt;
&lt;td&gt;&lt;a class="mentioned-user" href="https://dev.to/aj1732"&gt;@aj1732&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#15&lt;/td&gt;
&lt;td&gt;About section HTML&lt;/td&gt;
&lt;td&gt;@Florence-code-hub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#17&lt;/td&gt;
&lt;td&gt;Navigation HTML&lt;/td&gt;
&lt;td&gt;@MarionBraide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#19&lt;/td&gt;
&lt;td&gt;Footer HTML&lt;/td&gt;
&lt;td&gt;@Pheonixai&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#20&lt;/td&gt;
&lt;td&gt;Mission section HTML&lt;/td&gt;
&lt;td&gt;@luchiiii&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#23&lt;/td&gt;
&lt;td&gt;What We Offer section HTML&lt;/td&gt;
&lt;td&gt;@MarionBraide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#24&lt;/td&gt;
&lt;td&gt;Value Proposition section HTML&lt;/td&gt;
&lt;td&gt;@Okoukoni-Victor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#32&lt;/td&gt;
&lt;td&gt;CTA section HTML&lt;/td&gt;
&lt;td&gt;@Florence-code-hub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#34&lt;/td&gt;
&lt;td&gt;Hero section HTML&lt;/td&gt;
&lt;td&gt;@first-afk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#35&lt;/td&gt;
&lt;td&gt;Mission eyebrow image assets&lt;/td&gt;
&lt;td&gt;@luchiiii&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#36&lt;/td&gt;
&lt;td&gt;Navigation CSS&lt;/td&gt;
&lt;td&gt;@MarionBraide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#37&lt;/td&gt;
&lt;td&gt;About section CSS&lt;/td&gt;
&lt;td&gt;@Florence-code-hub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#39&lt;/td&gt;
&lt;td&gt;What We Offer section CSS&lt;/td&gt;
&lt;td&gt;@MarionBraide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#40&lt;/td&gt;
&lt;td&gt;Hero section CSS&lt;/td&gt;
&lt;td&gt;@first-afk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#42&lt;/td&gt;
&lt;td&gt;Value Proposition section CSS&lt;/td&gt;
&lt;td&gt;@Okoukoni-Victor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#43&lt;/td&gt;
&lt;td&gt;Mobile navigation toggle JS&lt;/td&gt;
&lt;td&gt;@MarionBraide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#44&lt;/td&gt;
&lt;td&gt;Contributors page (HTML + JS + CSS)&lt;/td&gt;
&lt;td&gt;&lt;a class="mentioned-user" href="https://dev.to/aj1732"&gt;@aj1732&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The architecture investment pays back immediately
&lt;/h3&gt;

&lt;p&gt;I spent the first hours of the sprint on things that produced no visible UI — folder structure, token system, CSS architecture, issue templates. Every hour I spent there saved the team two. When six people are styling six sections simultaneously, they are not stepping on each other because of architecture decisions made before they wrote their first rule.&lt;/p&gt;

&lt;p&gt;If you're leading a multi-contributor frontend project: &lt;strong&gt;set up the system before you invite contributors in.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Issue quality is a force multiplier
&lt;/h3&gt;

&lt;p&gt;The difference between "style the navigation" and a four-paragraph spec with Figma screenshots, BEM class names, acceptance criteria, and implementation notes is the difference between three revision cycles and one. Your time writing the issue is paid back in review time saved.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. CSS custom properties are a full design system
&lt;/h3&gt;

&lt;p&gt;I did not reach for Sass. I did not reach for Tailwind. CSS custom properties with a thoughtful primitive/semantic layer give you everything you need for a design system — theming, inheritance, component-level overrides, responsive tokens — in native CSS. The DX is excellent once contributors understand the naming convention.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Vanilla JS is underrated for constrained scopes
&lt;/h3&gt;

&lt;p&gt;The contributors page renderer, the mobile nav toggle, the offerings tab interaction — all written in plain JavaScript. No bundler, no framework, no dependency to maintain. The frontend opens directly in a browser. The bundle size is zero because there is no bundle. For a project at this stage, that is the right call.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. One file per section is not over-engineering
&lt;/h3&gt;

&lt;p&gt;It sounds like premature organisation. It isn't. When you have seven contributors and eight CSS sections, one file per section means eight people can make CSS changes in the same branch with zero merge conflicts. The cost is eight &lt;code&gt;@import&lt;/code&gt; lines in &lt;code&gt;landing.css&lt;/code&gt;. That's a good trade.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next for Ekehi
&lt;/h2&gt;

&lt;p&gt;The landing page sprint was phase one. What comes next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mission and CTA section CSS&lt;/strong&gt; — two open issues being finished by the frontend track&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend API&lt;/strong&gt; — Olusegun and Sodiq are building the Express + Supabase layer for the funding opportunity database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search and filter&lt;/strong&gt; — full-text search with sector, stage, location, and funding size filters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opportunity submission system&lt;/strong&gt; — funder-facing form with an admin review workflow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; — Supabase Auth + JWT for registered users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QA integration&lt;/strong&gt; — Gabriel's QA process formalised as acceptance test criteria against each API endpoint and UI flow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The codebase is open source. If you're a developer who wants to contribute to a project with real-world impact for women entrepreneurs across Africa — the issues are open, the CONTRIBUTING.md is thorough, and the bar for a good PR is clearly documented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/Tabi-Project/Ekehi" rel="noopener noreferrer"&gt;github.com/Tabi-Project/Ekehi&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Shoutout to the Team
&lt;/h2&gt;

&lt;p&gt;None of this ships without every one of these people.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend Track
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/MarionBraide" rel="noopener noreferrer"&gt;@MarionBraide&lt;/a&gt;&lt;/strong&gt; — Navigation HTML and CSS, What We Offer section, mobile nav JavaScript. Marion owns the most technically complex section on the page and delivered a clean, accessible, BEM-compliant implementation with a working JS interaction layer across four separate PRs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Florence-code-hub" rel="noopener noreferrer"&gt;@Florence-code-hub&lt;/a&gt;&lt;/strong&gt; — About section HTML and CSS, CTA section HTML. Florence nailed the mixed serif/sans heading treatment that gives Ekehi its distinct visual identity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/luchiiii" rel="noopener noreferrer"&gt;@luchiiii&lt;/a&gt;&lt;/strong&gt; (Okwuosa Oluchi) — Mission section HTML and the SVG decoration assets for the mission eyebrow. Oluchi also spotted and fixed missing &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags in a follow-up PR — exactly the attention to detail that keeps a shared codebase clean.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Okoukoni-Victor" rel="noopener noreferrer"&gt;@Okoukoni-Victor&lt;/a&gt;&lt;/strong&gt; (Victor Okoukoni) — Value Proposition section, HTML structure and CSS. Victor's responsive two-column layout with the visual block and caption section was clean from the first commit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/first-afk" rel="noopener noreferrer"&gt;@first-afk&lt;/a&gt;&lt;/strong&gt; (Esther Orieji) — Hero section HTML and CSS. Esther set the visual tone of the page with the wide-aspect hero display block that grounds the entire landing page.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Pheonixai" rel="noopener noreferrer"&gt;@Pheonixai&lt;/a&gt;&lt;/strong&gt; — Footer HTML. The footer is one of the most content-dense sections on the page — three navigation groups, a brand block, and a legal bottom bar — structured cleanly in a single PR.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  UI/UX Track
&lt;/h3&gt;

&lt;p&gt;The frontend sprint could only move at the speed it did because the designs were ready. Every section that got built had a Figma frame to implement against — with precise layout specs, color tokens, type scales, and component states defined before a single HTML file was touched.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Michael Babajide Boluwatife&lt;/strong&gt; — Core product design and design system foundation. The purple scale, the serif/sans heading pairing, the token naming convention — these design decisions shaped the CSS architecture built.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fisayo Rotibi&lt;/strong&gt; — Component and section design. Fisayo's contributor card designs directly informed the card rendering system, including the five SVG background variants and the per-card image positioning approach.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Working from a well-produced Figma file is a gift to a frontend engineer. You're not guessing. You're translating.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend Track
&lt;/h3&gt;

&lt;p&gt;While the frontend sprint was running, the backend team was laying the foundation for what Ekehi will become once the product goes live.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Olusegun Adeleke&lt;/strong&gt; — API architecture and database schema design. The Express MVC structure I scaffolded (&lt;code&gt;routes → controllers → models → services&lt;/code&gt;) was designed to directly receive Olusegun's implementations.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sodiq Semiu&lt;/strong&gt; — Backend development and Supabase integration. The &lt;code&gt;server/.env.example&lt;/code&gt; I included in the initial monorepo commit documents the Supabase keys and connection strings that Sodiq's work depends on.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The separation of &lt;code&gt;client/&lt;/code&gt; and &lt;code&gt;server/&lt;/code&gt; in the monorepo was intentional — frontend and backend contributors never needed to be in each other's directories. Both tracks moved independently on the same codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  QA
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gabriel Abubakar&lt;/strong&gt; — Quality assurance. Gabriel was the safety net across all three tracks — verifying that implementations matched the design specs, that mobile breakpoints held, and that interactions behaved as the issues defined. QA on a multi-track team is often underacknowledged. Without it, bugs that feel "done" in development show up in production.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This project matters. Women entrepreneurs in Africa are funding their businesses with imperfect information, or no information at all. Ekehi is a small piece of the solution to that — and it was built by a team of developers who showed up, picked up issues, and shipped.&lt;/p&gt;

&lt;p&gt;That's the kind of work worth documenting.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built for International Women's Day 2026 as part of the Tabî Project by TEE Foundation.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Open source — contributions welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>opensource</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
