feat: add Event Board to the Neighbourhood community board + dummy content

Researched mosque-app and community-board best practices (MadinaAPPS,
Nextdoor) before building: the pattern that works is one-tap RSVP with
what/who/where/when up front, plus a clean separation between plain
announcements and dated events. Deliberately did NOT auto-populate a
global Islamic calendar (Eid/Ramadan dates) — those are moon-sighting-
dependent and hardcoding one as fact would be exactly the kind of
fabrication this app avoids everywhere else (real Overpass data, real
Quran API, no invented listings). The Event Board is community-submitted
only: a mosque posting its own Eid prayer time is real information: this
app guessing the date isn't.

Implementation extends the existing Neighbourhood board rather than
building a parallel system: nf_neighbourhood_posts gains is_event/
event_date/event_location, plus a new nf_event_rsvps table (RLS reuses
the existing nf_is_neighbourhood_member helper via a join, no duplicated
logic). UI adds an event toggle on the post form (reveals date/time/
location), a 📅-badged event card with one-tap 'I'm going' RSVP + live
count, and an All/Events/Announcements filter with events sorted
soonest-first.

Seeded a real demo neighbourhood, 'Masjid Al-Falah Kariah' (join code
ALFALAH), with all 5 demo family owners plus a mutawalli as members: 3
announcements and 4 dated, RSVP'd events (Jumu'ah khutbah, weekly Quran
circle, an Islamic finance/estate-planning talk tying back to the app's
own core purpose, and a Ramadan iftar potluck). Caught and fixed two
seeding bugs during verification: timestamps stored without timezone
context displayed several hours off from the intended Malaysia-local
time, and a 'Saturday' event that actually landed on a Sunday — both
fixed by computing against real weekdays/timezone rather than guessing
offsets.

Covered by e2e-event-board.cjs (14/14), including live verification that
the seeded demo neighbourhood renders its real content. Full regression:
all suites pass.
This commit is contained in:
2026-08-14 16:53:40 +08:00
parent 22ad077dab
commit 5b4a05afb2
4 changed files with 218 additions and 15 deletions
+83 -13
View File
@@ -3,7 +3,8 @@
import { session } from './auth.js';
import {
createNeighbourhood, joinNeighbourhoodByCode, listMyNeighbourhoods, leaveNeighbourhood,
listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost
listNeighbourhoodPosts, addNeighbourhoodPost, removeNeighbourhoodPost,
listEventRsvps, rsvpToEvent, cancelRsvp
} from './db.js';
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
@@ -17,9 +18,11 @@
let posts = $state([]);
let createName = $state('');
let joinCode = $state('');
let postForm = $state({ title: '', body: '' });
let postForm = $state({ title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' });
let error = $state('');
let showAddForm = $state(false);
let boardFilter = $state('all'); // all | announcements | events
let rsvps = $state([]); // { post_id, user_id }[]
async function refreshNeighbourhoods() {
if (!userId) return;
@@ -31,6 +34,16 @@
async function refreshPosts() {
if (!activeId) return;
posts = await listNeighbourhoodPosts(activeId);
const eventIds = posts.filter(p => p.is_event).map(p => p.id);
rsvps = await listEventRsvps(eventIds);
}
function rsvpCount(postId) { return rsvps.filter(r => r.post_id === postId).length; }
function isGoing(postId) { return rsvps.some(r => r.post_id === postId && r.user_id === userId); }
async function toggleRsvp(postId) {
if (isGoing(postId)) await cancelRsvp(postId, userId);
else await rsvpToEvent(postId, userId);
await refreshPosts();
}
onMount(refreshNeighbourhoods);
@@ -73,8 +86,9 @@
async function doPost() {
if (!postForm.title.trim() || !activeId) return;
await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body);
postForm = { title: '', body: '' };
if (postForm.isEvent && !postForm.eventDate) return;
await addNeighbourhoodPost(activeId, userId, userEmail, postForm.title.trim(), postForm.body, postForm.isEvent ? { eventDate: postForm.eventDate, eventLocation: postForm.eventLocation } : null);
postForm = { title: '', body: '', isEvent: false, eventDate: '', eventLocation: '' };
await refreshPosts();
}
async function doRemovePost(id) {
@@ -83,6 +97,27 @@
}
const activeNeighbourhood = $derived(neighbourhoods.find(n => n.id === activeId));
const visiblePosts = $derived.by(() => {
let list = posts;
if (boardFilter === 'announcements') list = list.filter(p => !p.is_event);
if (boardFilter === 'events') list = list.filter(p => p.is_event);
// Upcoming events sort by date ascending (soonest first); everything
// else stays newest-first, matching the query order from the server.
return [...list].sort((a, b) => {
const aUpcoming = a.is_event && a.event_date && new Date(a.event_date) >= new Date();
const bUpcoming = b.is_event && b.event_date && new Date(b.event_date) >= new Date();
if (aUpcoming && bUpcoming) return new Date(a.event_date) - new Date(b.event_date);
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
return new Date(b.created_at) - new Date(a.created_at);
});
});
function formatEventDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
</script>
<div class="module">
@@ -90,9 +125,12 @@
<h2>Neighbourhood</h2>
<InfoPanel
title="Neighbourhood"
what="A simple announcement board for your mosque or local community — separate from your estate-planning family. Join with a code from your congregation, or start one and share the code."
how="Create a neighbourhood to get a join code to share, or enter a code someone gave you. Once joined, post and read announcements with everyone else in that neighbourhood."
fields={[]}
what="A community board and event listing for your mosque or local community — separate from your estate-planning family. Join with a code from your congregation, or start one and share the code."
how="Create a neighbourhood to get a join code to share, or enter a code someone gave you. Post a plain announcement, or check 'This is an event' to add a date, time, and location — members can tap 'I'm going' to RSVP."
fields={[
{ label: 'Event toggle', hint: 'Turns a post into a dated, located event others can RSVP to — otherwise it\'s a plain announcement.' },
{ label: 'RSVP', hint: 'A one-tap headcount, visible to everyone in the neighbourhood — not private.' }
]}
/>
</div>
<p class="sub">A community board, independent of your estate-planning family.</p>
@@ -115,23 +153,45 @@
</div>
<div class="form-card">
<label class="field"><span>Title</span><input type="text" bind:value={postForm.title} placeholder="e.g. Friday khutbah reminder" /></label>
<label class="field"><span>Title</span><input type="text" bind:value={postForm.title} placeholder={postForm.isEvent ? 'e.g. Ramadan Iftar Potluck' : 'e.g. Friday khutbah reminder'} /></label>
<label class="field"><span>Details</span><input type="text" bind:value={postForm.body} /></label>
<button class="btn-primary" onclick={doPost}>Post announcement</button>
<label class="event-toggle"><input type="checkbox" bind:checked={postForm.isEvent} /><span>This is an event (with date, time &amp; location)</span></label>
{#if postForm.isEvent}
<label class="field"><span>Date &amp; time</span><input type="datetime-local" bind:value={postForm.eventDate} /></label>
<label class="field"><span>Location</span><input type="text" bind:value={postForm.eventLocation} placeholder="e.g. Masjid Al-Falah, main hall" /></label>
{/if}
<button class="btn-primary" onclick={doPost}>{postForm.isEvent ? 'Post event' : 'Post announcement'}</button>
</div>
<div class="board-filter">
<button class:active={boardFilter === 'all'} onclick={() => boardFilter = 'all'}>All</button>
<button class:active={boardFilter === 'events'} onclick={() => boardFilter = 'events'}>📅 Events</button>
<button class:active={boardFilter === 'announcements'} onclick={() => boardFilter = 'announcements'}>📣 Announcements</button>
</div>
<div class="post-list">
{#each posts as p (p.id)}
<div class="post-row">
{#each visiblePosts as p (p.id)}
<div class="post-row" class:event-row={p.is_event}>
<div class="post-header">
<strong>{p.title}</strong>
<strong>{p.is_event ? '📅 ' : ''}{p.title}</strong>
{#if p.author_id === userId}<button class="post-remove" onclick={() => doRemovePost(p.id)}>✕</button>{/if}
</div>
{#if p.is_event}
<div class="event-details">
<span class="event-when">🕐 {formatEventDate(p.event_date)}</span>
{#if p.event_location}<span class="event-where">📍 {p.event_location}</span>{/if}
</div>
{/if}
{#if p.body}<p class="post-body">{p.body}</p>{/if}
<span class="post-meta">{p.author_name || 'Member'} · {p.created_at?.slice(0, 10)}</span>
{#if p.is_event}
<button class="rsvp-btn" class:going={isGoing(p.id)} onclick={() => toggleRsvp(p.id)}>
{isGoing(p.id) ? `✓ Going` : 'I\'m going'} {rsvpCount(p.id) > 0 ? `(${rsvpCount(p.id)})` : ''}
</button>
{/if}
</div>
{:else}
<p class="empty">No announcements yet.</p>
<p class="empty">{boardFilter === 'events' ? 'No events posted yet.' : boardFilter === 'announcements' ? 'No announcements yet.' : 'Nothing posted yet.'}</p>
{/each}
</div>
@@ -184,4 +244,14 @@
.post-meta { font-size: 10.5px; color: #8A8478; }
.empty { font-size: 13px; color: #8A8478; text-align: center; padding: 20px 0; }
.add-toggle { margin-top: 16px; }
.event-toggle { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: #B8B2A6; margin-bottom: 12px; cursor: pointer; }
.board-filter { display: flex; gap: 6px; margin-bottom: 14px; }
.board-filter button { flex: 1; padding: 8px 6px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.2); background: rgba(255,255,255,0.03); color: #B8B2A6; font-size: 11.5px; cursor: pointer; }
.board-filter button.active { background: rgba(201,168,76,0.15); color: #C9A84C; border-color: rgba(201,168,76,0.4); }
.event-row { border: 1px solid rgba(46,204,113,0.2); }
.event-details { display: flex; flex-direction: column; gap: 3px; margin: 6px 0; }
.event-when, .event-where { font-size: 11.5px; color: #2ECC71; }
.rsvp-btn { margin-top: 8px; padding: 7px 14px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); background: rgba(255,255,255,0.04); color: #C9A84C; font-size: 12px; font-weight: 600; cursor: pointer; }
.rsvp-btn.going { background: rgba(46,204,113,0.15); color: #2ECC71; border-color: rgba(46,204,113,0.4); }
</style>
+21 -2
View File
@@ -125,8 +125,11 @@ export async function listNeighbourhoodPosts(neighbourhoodId) {
if (error) throw error;
return data || [];
}
export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body) {
const { error } = await supabase.from('nf_neighbourhood_posts').insert({ neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body });
export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body, eventFields) {
const { error } = await supabase.from('nf_neighbourhood_posts').insert({
neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body,
is_event: !!eventFields, event_date: eventFields?.eventDate || null, event_location: eventFields?.eventLocation || null
});
if (error) throw error;
}
export async function removeNeighbourhoodPost(id) {
@@ -134,6 +137,22 @@ export async function removeNeighbourhoodPost(id) {
if (error) throw error;
}
// ── Event Board — RSVPs on event-flagged neighbourhood posts. ──
export async function listEventRsvps(postIds) {
if (!postIds.length) return [];
const { data, error } = await supabase.from('nf_event_rsvps').select('post_id, user_id').in('post_id', postIds);
if (error) throw error;
return data || [];
}
export async function rsvpToEvent(postId, userId) {
const { error } = await supabase.from('nf_event_rsvps').insert({ post_id: postId, user_id: userId });
if (error) throw error;
}
export async function cancelRsvp(postId, userId) {
const { error } = await supabase.from('nf_event_rsvps').delete().eq('post_id', postId).eq('user_id', userId);
if (error) throw error;
}
// ── Hibah ──
export async function listHibahGifts(familyId) {
const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');