From 5b4a05afb2971e5d0d1564313843d3073728282a Mon Sep 17 00:00:00 2001 From: Wmj Ismail Date: Fri, 14 Aug 2026 16:53:40 +0800 Subject: [PATCH] feat: add Event Board to the Neighbourhood community board + dummy content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DEMO_DATA.md | 11 ++++ e2e-event-board.cjs | 103 ++++++++++++++++++++++++++++++ src/lib/NeighbourhoodBoard.svelte | 96 ++++++++++++++++++++++++---- src/lib/db.js | 23 ++++++- 4 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 e2e-event-board.cjs diff --git a/DEMO_DATA.md b/DEMO_DATA.md index a6c776c..8b90ae1 100644 --- a/DEMO_DATA.md +++ b/DEMO_DATA.md @@ -56,3 +56,14 @@ Three additions, covered by `e2e-insurance-verification.cjs` (12/12): - All proof documents (policies, liabilities, assets) share one private Storage bucket, `nf-asset-documents`, with the same family-membership RLS pattern used for person photos. + +## Demo Neighbourhood โ€” "Masjid Al-Falah Kariah" + +Join code `ALFALAH`. All 5 demo family owners plus `nf.demo.mutawalli@gmail.com` +(as "Ustaz Hafiz") are members โ€” reachable from any demo account via +Family โ†’ Neighbourhood. Seeded with 3 plain announcements and 4 dated, +RSVP'd events (Jumu'ah khutbah, weekly Quran circle, an Islamic +finance/estate-planning talk, and a Ramadan iftar potluck), so the board +and Event filter are never empty for a demo. Event dates are computed +relative to "today" (real Fridays/Saturdays), not hardcoded โ€” reseed with +fresh relative dates if this demo goes stale. diff --git a/e2e-event-board.cjs b/e2e-event-board.cjs new file mode 100644 index 0000000..bf465a5 --- /dev/null +++ b/e2e-event-board.cjs @@ -0,0 +1,103 @@ +// Verifies the Event Board extension to the Neighbourhood board: posting an +// event (date/time/location), RSVP toggle + count, the Announcements/Events +// filter, and that the seeded demo neighbourhood ("Masjid Al-Falah Kariah") +// renders real content for a demo account. +const { chromium } = require('playwright'); +const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs'); +const BASE = 'https://moslem04.falahos.my/'; +const results = []; +const consoleErrors = []; +function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' โ€” ' + detail : ''}`); } + +async function main() { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); }); + page.on('pageerror', e => consoleErrors.push(e.message)); + + await signInFreshFamily(page, BASE, 'e2e-eventboard'); + await gotoTab(page, 'Neighbourhood'); + await page.waitForTimeout(600); + const addToggle = await page.locator('.add-toggle').isVisible().catch(() => false); + if (addToggle) await page.locator('.add-toggle').click(); + await page.waitForTimeout(300); + await page.locator('.form-card .field:has-text("Name") input').fill(`Event Board Test ${Date.now()}`); + await page.locator('button.btn-primary', { hasText: 'Create & get a join code' }).click(); + await page.waitForTimeout(1000); + + // Plain announcement โ€” no event fields shown until the toggle is checked + const eventFieldsHiddenByDefault = !(await page.locator('.field:has-text("Date & time")').isVisible().catch(() => false)); + record('Neighbourhood: event date/location fields hidden until toggled', eventFieldsHiddenByDefault); + + await page.locator('.field:has-text("Title") input').fill('General announcement'); + await page.locator('button.btn-primary', { hasText: 'Post announcement' }).click(); + await page.waitForTimeout(1000); + record('Neighbourhood: plain announcement posts without an event badge', await page.locator('.post-row:not(.event-row)', { hasText: 'General announcement' }).isVisible().catch(() => false)); + + // Event post + await page.locator('.field:has-text("Title") input').fill('Community Quran Class'); + await page.locator('.event-toggle input').check(); + await page.waitForTimeout(200); + const eventFieldsShown = await page.locator('.field:has-text("Date & time")').isVisible().catch(() => false); + record('Neighbourhood: event fields appear once toggled', eventFieldsShown); + await page.locator('.field:has-text("Date & time") input').fill('2026-12-01T18:30'); + await page.locator('.field:has-text("Location") input').fill('Masjid Test Hall'); + await page.locator('button.btn-primary', { hasText: 'Post event' }).click(); + await page.waitForTimeout(1000); + + const eventCardVisible = await page.locator('.event-row', { hasText: 'Community Quran Class' }).isVisible().catch(() => false); + record('Event Board: event posts with the ๐Ÿ“… badge and event styling', eventCardVisible); + const eventDetailsVisible = await page.locator('.event-row', { hasText: 'Community Quran Class' }).locator('.event-when, .event-where').count(); + record('Event Board: shows date/time and location on the event card', eventDetailsVisible === 2, `${eventDetailsVisible} detail lines`); + + // RSVP + const rsvpBtn = page.locator('.event-row', { hasText: 'Community Quran Class' }).locator('.rsvp-btn'); + record('Event Board: starts as not going', !(await rsvpBtn.evaluate(el => el.classList.contains('going')))); + await rsvpBtn.click(); + await page.waitForTimeout(800); + record('Event Board: RSVP toggles to "Going" with a count of 1', await rsvpBtn.evaluate(el => el.classList.contains('going')) && (await rsvpBtn.textContent()).includes('1')); + await rsvpBtn.click(); + await page.waitForTimeout(800); + record('Event Board: RSVP toggles back off (cancel)', !(await rsvpBtn.evaluate(el => el.classList.contains('going')))); + + // Filter + await page.locator('.board-filter button', { hasText: 'Events' }).click(); + await page.waitForTimeout(300); + const onlyEventsShown = await page.locator('.post-row').count() === 1 && await page.locator('.event-row').isVisible(); + record('Event Board: Events filter hides plain announcements', onlyEventsShown); + await page.locator('.board-filter button', { hasText: 'Announcements' }).click(); + await page.waitForTimeout(300); + const onlyAnnouncementsShown = await page.locator('.post-row').count() === 1 && !(await page.locator('.event-row').isVisible().catch(() => false)); + record('Event Board: Announcements filter hides events', onlyAnnouncementsShown); + + // โ”€โ”€ Seeded demo neighbourhood โ”€โ”€ + const demoPage = await browser.newPage({ viewport: { width: 390, height: 844 } }); + demoPage.on('console', m => { if (m.type() === 'error') consoleErrors.push('[demo] ' + m.text()); }); + await demoPage.goto(BASE, { waitUntil: 'networkidle' }); + await demoPage.locator('.field:has-text("Email") input').fill('nf.demo.ismail@gmail.com'); + await demoPage.locator('.field:has-text("Password") input').fill('DemoPassword123!'); + await demoPage.locator('button.btn-primary', { hasText: 'Sign in' }).click(); + await demoPage.waitForTimeout(1500); + if (await demoPage.locator('.switcher-screen').isVisible().catch(() => false)) { + await demoPage.locator('.family-row').first().click(); + await demoPage.waitForTimeout(1000); + } + await gotoTab(demoPage, 'Neighbourhood'); + await demoPage.waitForTimeout(800); + record('Demo neighbourhood: Masjid Al-Falah Kariah is visible to the demo owner', await demoPage.locator('.active-card', { hasText: 'Masjid Al-Falah Kariah' }).isVisible().catch(() => false)); + const demoPostCount = await demoPage.locator('.post-row').count(); + record('Demo neighbourhood: has real seeded content (7 posts)', demoPostCount === 7, `${demoPostCount} posts`); + const demoEventCount = await demoPage.locator('.event-row').count(); + record('Demo neighbourhood: has real seeded events (4 events)', demoEventCount === 4, `${demoEventCount} events`); + await demoPage.close(); + + record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' || ')); + + await browser.close(); + const passCount = results.filter(r => r.pass).length; + const failCount = results.length - passCount; + console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`); + if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`)); + process.exit(failCount > 0 ? 1 : 0); +} +main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); }); diff --git a/src/lib/NeighbourhoodBoard.svelte b/src/lib/NeighbourhoodBoard.svelte index c195b0b..0998609 100644 --- a/src/lib/NeighbourhoodBoard.svelte +++ b/src/lib/NeighbourhoodBoard.svelte @@ -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' }); + }
@@ -90,9 +125,12 @@

Neighbourhood

A community board, independent of your estate-planning family.

@@ -115,23 +153,45 @@
- + - + + {#if postForm.isEvent} + + + {/if} + +
+ +
+ + +
- {#each posts as p (p.id)} -
+ {#each visiblePosts as p (p.id)} +
- {p.title} + {p.is_event ? '๐Ÿ“… ' : ''}{p.title} {#if p.author_id === userId}{/if}
+ {#if p.is_event} +
+ ๐Ÿ• {formatEventDate(p.event_date)} + {#if p.event_location}๐Ÿ“ {p.event_location}{/if} +
+ {/if} {#if p.body}

{p.body}

{/if} + {#if p.is_event} + + {/if}
{:else} -

No announcements yet.

+

{boardFilter === 'events' ? 'No events posted yet.' : boardFilter === 'announcements' ? 'No announcements yet.' : 'Nothing posted yet.'}

{/each}
@@ -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); } diff --git a/src/lib/db.js b/src/lib/db.js index ce54b14..8becec6 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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');