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.