Last Week
The transition from mock data to real backend integration began in earnest. The home dashboard, which had been displaying carefully crafted fake data, started connecting to the actual Supabase repository through properly architected domain and data layers.
Connecting the Layers
The waitlist management features are now fully operational – users can view their created waitlists and add new ones through a complete vertical slice of the architecture. The guest list details pane is partially implemented, with some testing challenges arising from the transition from mock to real implementation. The focus remains on maintaining strict test coverage while building out the remaining UI connections.
What does it mean in English?
Imagine building a car dashboard that initially shows fake readings on paper cards. Now we’re connecting real sensors so the speedometer shows actual speed, the fuel gauge shows real fuel levels, and all the buttons actually control the engine. The challenge is ensuring everything still works perfectly while swapping fake parts for real ones – including all the safety tests that verify nothing breaks.
Nerdy Details
The Architecture Layers in Action
The transition from mock to real data involves coordinating across four distinct architectural layers, each with specific responsibilities:
// Presentation Layer - ViewModels orchestrating UI state
class WaitlistViewModel(
private val getWaitlistsUseCase: GetWaitlistsUseCase,
private val addWaitlistUseCase: AddWaitlistUseCase,
private val observeWaitlistsUseCase: ObserveWaitlistsUseCase
) : ViewModel(), ContainerHost<WaitlistState, WaitlistSideEffect> {
override val container = container<WaitlistState, WaitlistSideEffect>(
WaitlistState()
)
init {
observeWaitlists()
loadWaitlists()
}
private fun observeWaitlists() = intent {
observeWaitlistsUseCase().collect { waitlists ->
reduce {
state.copy(
waitlists = waitlists,
isLoading = false
)
}
}
}
fun onAddWaitlist(name: String, capacity: Int) = intent {
reduce { state.copy(isAddingWaitlist = true) }
addWaitlistUseCase(
WaitlistInput(
name = name,
maxCapacity = capacity,
currentDate = Clock.System.now()
)
).fold(
onSuccess = { waitlist ->
reduce { state.copy(isAddingWaitlist = false) }
postSideEffect(WaitlistSideEffect.ShowSuccess("Waitlist created"))
},
onFailure = { error ->
reduce { state.copy(isAddingWaitlist = false) }
postSideEffect(WaitlistSideEffect.ShowError(error.message))
}
)
}
}
// Domain Layer - Business logic and use cases
class GetWaitlistsUseCase(
private val waitlistRepository: WaitlistRepository,
private val userRepository: UserRepository
) {
suspend operator fun invoke(): Result<List<Waitlist>> {
return userRepository.getCurrentUser().flatMap { user ->
waitlistRepository.getWaitlistsForUser(user.id)
}
}
}
class AddWaitlistUseCase(
private val waitlistRepository: WaitlistRepository,
private val validator: WaitlistValidator
) {
suspend operator fun invoke(input: WaitlistInput): Result<Waitlist> {
// Validate business rules
validator.validate(input).onFailure { return Result.failure(it) }
val waitlist = Waitlist(
id = WaitlistId.generate(),
name = input.name,
maxCapacity = input.maxCapacity,
currentSize = 0,
status = WaitlistStatus.ACTIVE,
createdAt = input.currentDate
)
return waitlistRepository.create(waitlist)
}
}
// Data Layer - Repository implementations
class SupabaseWaitlistRepository(
private val supabaseClient: SupabaseClient,
private val mapper: WaitlistMapper
) : WaitlistRepository {
override suspend fun getWaitlistsForUser(userId: UserId): Result<List<Waitlist>> {
return try {
val response = supabaseClient
.from("waitlists")
.select(columns = Columns.ALL)
.filter {
eq("owner_id", userId.value)
eq("deleted_at", null)
}
.decodeList<WaitlistDto>()
Result.success(response.map { mapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(RepositoryException("Failed to fetch waitlists", e))
}
}
override suspend fun create(waitlist: Waitlist): Result<Waitlist> {
return try {
val dto = mapper.toDto(waitlist)
val response = supabaseClient
.from("waitlists")
.insert(dto)
.decodeSingle<WaitlistDto>()
Result.success(mapper.toDomain(response))
} catch (e: Exception) {
when {
e.message?.contains("duplicate") == true ->
Result.failure(DuplicateWaitlistException(waitlist.name))
else ->
Result.failure(RepositoryException("Failed to create waitlist", e))
}
}
}
override fun observeWaitlists(userId: UserId): Flow<List<Waitlist>> {
return supabaseClient
.from("waitlists")
.selectAsFlow(
filter = FilterOperation.and(
FilterOperation.eq("owner_id", userId.value),
FilterOperation.eq("deleted_at", null)
)
)
.map { result ->
result.decodeList<WaitlistDto>().map { mapper.toDomain(it) }
}
.catch { emit(emptyList()) }
}
}
The Guest List Implementation Challenge
The guest list details pane presents unique challenges when transitioning from mock to real data:
// The mock implementation was simple
class MockGuestRepository : GuestRepository {
private val mockGuests = mutableListOf<Guest>()
override suspend fun getGuestsForWaitlist(
waitlistId: WaitlistId
): Result<List<Guest>> {
return Result.success(
mockGuests.filter { it.waitlistId == waitlistId }
)
}
}
// The real implementation requires complex joins and real-time updates
class SupabaseGuestRepository(
private val supabaseClient: SupabaseClient,
private val mapper: GuestMapper
) : GuestRepository {
override suspend fun getGuestsForWaitlist(
waitlistId: WaitlistId
): Result<List<Guest>> {
return try {
// Complex query with joins for related data
val response = supabaseClient
.from("guests")
.select("""
*,
waitlist:waitlists!inner(
id,
name,
max_capacity
),
notifications:guest_notifications(
sent_at,
type,
status
)
""")
.filter {
eq("waitlist_id", waitlistId.value)
order("position", ascending = true)
}
.decodeList<GuestWithRelationsDto>()
val guests = response.map { dto ->
mapper.toDomain(dto).copy(
estimatedWaitTime = calculateWaitTime(dto.position)
)
}
Result.success(guests)
} catch (e: Exception) {
Result.failure(RepositoryException("Failed to fetch guests", e))
}
}
private fun calculateWaitTime(position: Int): Duration {
// Complex calculation based on historical data
return position.minutes * averageServiceTime
}
}
Test Migration Strategy
Replacing mock tests with real implementation tests requires careful planning:
// Old mock test - simple and fast
class WaitlistViewModelMockTest {
private val mockRepository = MockWaitlistRepository()
@Test
fun `add waitlist shows in list`() = runTest {
val viewModel = WaitlistViewModel(
GetWaitlistsUseCase(mockRepository),
AddWaitlistUseCase(mockRepository, WaitlistValidator())
)
viewModel.onAddWaitlist("Test List", 10)
assertEquals(1, viewModel.container.stateFlow.value.waitlists.size)
}
}
// New integration test - requires test database
class WaitlistViewModelIntegrationTest {
companion object {
private lateinit var testContainer: PostgreSQLContainer<*>
private lateinit var testClient: SupabaseClient
@BeforeClass
@JvmStatic
fun setupTestEnvironment() {
testContainer = PostgreSQLContainer("postgres:15")
.withInitScript("test-schema.sql")
testContainer.start()
testClient = createTestSupabaseClient(
testContainer.host,
testContainer.getMappedPort(8000)
)
// Seed test data
runBlocking {
seedTestUser()
seedTestWaitlists()
}
}
}
@Test
fun `add waitlist persists to database`() = runIntegrationTest {
val repository = SupabaseWaitlistRepository(testClient, WaitlistMapper())
val viewModel = WaitlistViewModel(
GetWaitlistsUseCase(repository, TestUserRepository()),
AddWaitlistUseCase(repository, WaitlistValidator()),
ObserveWaitlistsUseCase(repository)
)
viewModel.onAddWaitlist("Test List", 10)
// Wait for async operations
delay(500)
// Verify in database
val dbResult = testClient
.from("waitlists")
.select()
.filter { eq("name", "Test List") }
.decodeSingleOrNull<WaitlistDto>()
assertNotNull(dbResult)
assertEquals(10, dbResult.maxCapacity)
// Verify in UI state
val state = viewModel.container.stateFlow.value
assertTrue(state.waitlists.any { it.name == "Test List" })
}
@Test
fun `concurrent waitlist operations maintain consistency`() = runIntegrationTest {
val repository = SupabaseWaitlistRepository(testClient, WaitlistMapper())
val viewModel = createViewModel(repository)
// Simulate concurrent operations
val jobs = (1..10).map { index ->
async {
viewModel.onAddWaitlist("Concurrent $index", index * 10)
}
}
jobs.awaitAll()
delay(1000) // Wait for all operations
val state = viewModel.container.stateFlow.value
assertEquals(10, state.waitlists.size)
// Verify no duplicates
val names = state.waitlists.map { it.name }
assertEquals(names.size, names.distinct().size)
}
}
The 95% Coverage Challenge with Real Implementations
Maintaining 95% test coverage becomes more complex with real backend integration:
// Coverage configuration
kover {
verify {
rule {
name = "Domain layer coverage"
target = kotlinx.kover.api.VerificationTarget.CLASS
includes = listOf("*.domain.*")
minBound(95) // 95% line coverage required
}
rule {
name = "Data layer coverage"
target = kotlinx.kover.api.VerificationTarget.CLASS
includes = listOf("*.data.*")
minBound(90) // 90% for data layer (integration tests are expensive)
}
}
}
// Test helper for comprehensive coverage
class RepositoryTestHelper {
fun testAllErrorScenarios(repository: WaitlistRepository) = runTest {
// Network failure
withNetworkFailure {
val result = repository.getWaitlistsForUser(testUserId)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is NetworkException)
}
// Invalid data
val invalidWaitlist = Waitlist(
name = "", // Empty name
maxCapacity = -1 // Negative capacity
)
val result = repository.create(invalidWaitlist)
assertTrue(result.isFailure)
// Concurrent modification
val waitlist = createTestWaitlist()
coroutineScope {
val job1 = async { repository.update(waitlist.copy(name = "Name1")) }
val job2 = async { repository.update(waitlist.copy(name = "Name2")) }
val results = awaitAll(job1, job2)
assertTrue(results.any { it.isFailure })
}
// Rate limiting
repeat(100) {
repository.getWaitlistsForUser(testUserId)
}
// Should eventually hit rate limit
}
}
Platform-Specific Compilation Strategy
The CI/CD pipeline carefully manages GitHub Actions minutes:
# .github/workflows/build.yml
name: Build and Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
android-desktop-build:
runs-on: ubuntu-latest # 1x multiplier for minutes
timeout-minutes: 15 # Strict timeout to preserve minutes
steps:
- uses: actions/checkout@v3
- name: Cache Gradle dependencies
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Build Android
run: ./gradlew assembleDebug --parallel --build-cache
- name: Build Desktop
run: ./gradlew packageDistributionForCurrentOS --parallel --build-cache
- name: Run tests with coverage
run: ./gradlew koverVerify --parallel --build-cache
ios-build:
# Only run on main branch to save minutes
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: macos-latest # 10x multiplier for minutes!
timeout-minutes: 10 # Very strict timeout
steps:
- uses: actions/checkout@v3
- name: Check if iOS code changed
id: ios-changes
run: |
if git diff --name-only HEAD~1 HEAD | grep -q "iosMain\|iosApp"; then
echo "ios_changed=true" >> $GITHUB_OUTPUT
else
echo "ios_changed=false" >> $GITHUB_OUTPUT
fi
- name: Build iOS
if: steps.ios-changes.outputs.ios_changed == 'true'
run: |
./gradlew linkDebugFrameworkIosX64 --parallel --build-cache
# Skip actual Xcode build to save minutes
Minute Usage Optimization Strategies
// Local pre-push hook to prevent unnecessary CI runs
// .git/hooks/pre-push
#!/bin/bash
echo "Running local checks to save GitHub Actions minutes..."
# Quick local compilation check
./gradlew compileKotlin --parallel --offline || {
echo "❌ Local compilation failed. Fix before pushing."
exit 1
}
# Run fast unit tests locally
./gradlew testDebugUnitTest --parallel --offline || {
echo "❌ Unit tests failed. Fix before pushing."
exit 1
}
# Check if iOS files changed
if git diff --name-only @{u}..HEAD | grep -q "iosMain\|iosApp"; then
echo "⚠️ iOS files changed. This will trigger expensive macOS runner."
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
echo "✅ Local checks passed. Pushing to remote."
Edge Functions for Email Distribution
Planning for Supabase Edge Functions to handle email notifications:
// supabase/functions/send-notification/index.ts
import { serve } from "https://deno.land/[email protected]/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
interface NotificationRequest {
guestId: string
type: 'ready' | 'reminder' | 'cancelled'
waitlistName: string
estimatedTime?: number
}
serve(async (req: Request) => {
try {
const { guestId, type, waitlistName, estimatedTime } = await req.json()
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Fetch guest details
const { data: guest, error } = await supabase
.from('guests')
.select('email, name, phone')
.eq('id', guestId)
.single()
if (error) throw error
// Prepare email content
const emailContent = prepareEmailContent(type, {
guestName: guest.name,
waitlistName,
estimatedTime
})
// Send via Resend API (cheaper than SMS)
const resendResponse = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: 'Shokken <[email protected]>',
to: guest.email,
subject: emailContent.subject,
html: emailContent.html
})
})
if (!resendResponse.ok) {
throw new Error(`Email send failed: ${resendResponse.statusText}`)
}
// Log notification
await supabase
.from('notification_logs')
.insert({
guest_id: guestId,
type,
channel: 'email',
sent_at: new Date().toISOString(),
status: 'sent'
})
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
}
})
function prepareEmailContent(
type: string,
data: any
): { subject: string; html: string } {
const templates = {
ready: {
subject: `Your table is ready at ${data.waitlistName}!`,
html: `
<h2>Hi ${data.guestName}!</h2>
<p>Great news! Your table is ready at ${data.waitlistName}.</p>
<p>Please proceed to the host stand.</p>
`
},
reminder: {
subject: `You're almost up at ${data.waitlistName}`,
html: `
<h2>Hi ${data.guestName}!</h2>
<p>You're next in line! Estimated wait: ${data.estimatedTime} minutes.</p>
<p>Please be ready when called.</p>
`
}
}
return templates[type] || templates.ready
}
Cost Considerations for MVP
Without RevenueCat or SMS in the MVP, the cost structure simplifies:
// Cost tracking for email notifications
data class NotificationCost(
val emailsSent: Int,
val costPerEmail: Double = 0.001, // Resend pricing
val freeTeir: Int = 3000 // Monthly free emails
) {
val totalCost: Double
get() = maxOf(0.0, (emailsSent - freeTeir) * costPerEmail)
val projectedMonthlyCost: Double
get() = totalCost * 30 // Rough projection
}
// Analytics to monitor costs
class CostMonitoringService(
private val supabaseClient: SupabaseClient
) {
suspend fun getMonthlyUsage(): NotificationCost {
val count = supabaseClient
.from("notification_logs")
.select(columns = Columns.raw("count(*)"))
.filter {
gte("sent_at", firstDayOfMonth())
eq("channel", "email")
}
.decodeSingle<CountResult>()
.count
return NotificationCost(emailsSent = count)
}
suspend fun shouldThrottle(): Boolean {
val usage = getMonthlyUsage()
// Start throttling at 80% of budget
return usage.projectedMonthlyCost > MONTHLY_BUDGET * 0.8
}
}
Next Week
The focus continues on completing the guest list details pane and add guest functionality, while addressing the test migration challenges. Once these core features are wired up, the path clears for implementing specialized features like edge functions for notifications. The iOS compilation verification remains deferred to preserve precious GitHub Actions minutes.
Every architectural decision now – from test strategies to CI/CD optimizations – shapes the maintainability and scalability of the production system.