Dashboard Common Tasks Guide
Developer cookbook for common modifications and enhancements to the Dashboard feature.
Table of Contents
- Adding New Metrics to Dashboard Response
- Modifying Next Task Selection Logic
- Modifying Next Habit Selection Logic
- Adding New Widget Types
- Implementing New Frequency Types
- Debugging Overdue Calculation Issues
- Optimizing Dashboard Query Performance
- Adding New Platform Support
- Testing Dashboard Functionality
Adding New Metrics to Dashboard Response
Use Case: You want to add a new metric like “total steps walked today” to the dashboard.
Step 1: Add Field to DashboardResponseDTO
File: /shared/src/commonMain/kotlin/models/dashboard/DashboardResponseDTO.kt
@Serializable
data class DashboardResponseDTO(
// ... existing fields ...
val weeklyMealLogging: Float,
// NEW FIELD
val todaySteps: Int = 0,
val weeklySteps: Int = 0,
// ... remaining fields ...
)Step 2: Add Service Dependency (if needed)
File: /server/src/main/kotlin/com/esteban/ruano/service/DashboardService.kt
class DashboardService(
private val taskService: TaskService,
private val habitService: HabitService,
// ... existing services ...
private val activityService: ActivityService // NEW
) {Step 3: Update KoinConfig
File: /server/src/main/kotlin/com/esteban/ruano/plugins/KoinConfig.kt
single { DashboardService(
get(), get(), get(), get(), get(), get(), get(),
get() // Add new service here
) }Step 4: Fetch Data in getDashboardData
File: /server/src/main/kotlin/com/esteban/ruano/service/DashboardService.kt
Location: Around line 95 (after existing service calls)
fun getDashboardData(userId: Int, dateTime: String): DashboardResponseDTO {
// ... existing code ...
// NEW: Fetch activity data
val todaySteps = activityService.getStepsForDate(userId, today)
val weeklySteps = activityService.getStepsForWeek(userId, weekStart, weekEnd)
return DashboardResponseDTO(
// ... existing fields ...
todaySteps = todaySteps,
weeklySteps = weeklySteps,
// ... remaining fields ...
)
}Step 5: Test the New Metric
// In DashboardServiceTest.kt
@Test
fun `dashboard includes activity metrics`() {
// Given
val userId = 1
val dateTime = "2024-01-16T14:30:00"
// Mock activityService response
every { activityService.getStepsForDate(userId, any()) } returns 8500
every { activityService.getStepsForWeek(userId, any(), any()) } returns 45000
// When
val result = dashboardService.getDashboardData(userId, dateTime)
// Then
assertEquals(8500, result.todaySteps)
assertEquals(45000, result.weeklySteps)
}Modifying Next Task Selection Logic
Use Case: Change priority threshold or add new prioritization criteria.
Example: Change High Priority Threshold
File: /server/src/main/kotlin/com/esteban/ruano/service/DashboardService.kt
Location: Line 268
Current Code:
val highPriorityTasks = todaysTasks.filter { it.priority >= 4 }Modified Code (lower threshold to 3):
val highPriorityTasks = todaysTasks.filter { it.priority >= 3 }Example: Add Tag-Based Prioritization
Location: Line 232-295 (entire getNextTask function)
Add After Line 245:
// Separate tasks into categories
val overdueTasks = pendingTasks.filter { /* ... */ }
// NEW: Further prioritize by tag
val urgentOverdueTasks = overdueTasks.filter { it.tags?.contains("urgent") == true }
if (urgentOverdueTasks.isNotEmpty()) {
return urgentOverdueTasks.maxByOrNull { it.priority }
}
val todaysTasks = pendingTasks.filter { /* ... */ }Example: Consider Estimated Duration
Add Before Return Statement (around line 276):
// Return by priority first, then by duration (shorter tasks first)
return todaysTasks.maxByOrNull { it.priority }?.let { highPriorityTask ->
val samePriorityTasks = todaysTasks.filter { it.priority == highPriorityTask.priority }
samePriorityTasks.minByOrNull {
it.estimatedDuration ?: Int.MAX_VALUE // Prefer tasks with shorter duration
}
}Modifying Next Habit Selection Logic
Use Case: Change how the next habit is selected.
Example: Prioritize Habits by Streak
File: /server/src/main/kotlin/com/esteban/ruano/service/DashboardService.kt
Location: Line 389-394 (selection logic)
Current Code:
val result = when {
futureTimesToday.isNotEmpty() -> futureTimesToday.first().habit
pastTimesToday.isNotEmpty() -> pastTimesToday.first().habit
futureHabits.isNotEmpty() -> futureHabits.minByOrNull { it.nextOccurrenceDateTime }?.habit
else -> nonOverdueHabitsWithOccurrence.firstOrNull()?.habit
}Modified Code (prioritize by streak):
val result = when {
futureTimesToday.isNotEmpty() -> {
// Prioritize habits with longer streaks to maintain momentum
futureTimesToday.maxByOrNull { it.habit.streak }?.habit
}
pastTimesToday.isNotEmpty() -> {
pastTimesToday.maxByOrNull { it.habit.streak }?.habit
}
futureHabits.isNotEmpty() -> {
futureHabits.maxByOrNull { it.habit.streak }?.habit
}
else -> nonOverdueHabitsWithOccurrence.firstOrNull()?.habit
}Example: Filter Habits by Category
Add After Line 301 (filtering pending habits):
val pendingHabits = habits.filter { !it.done }
// NEW: Only consider habits from specific categories
val categoryFilter = listOf("health", "productivity")
val filteredHabits = pendingHabits.filter { habit ->
habit.category in categoryFilter
}
if (filteredHabits.isEmpty()) return nullAdding New Widget Types
Use Case: Create a new dashboard widget for displaying custom data.
Step 1: Define Widget Type Constant
File: Create /shared/src/commonMain/kotlin/models/dashboard/WidgetTypes.kt (if doesn’t exist)
object WidgetTypes {
const val TASK_OVERVIEW = "TASK_OVERVIEW"
const val HABIT_OVERVIEW = "HABIT_OVERVIEW"
const val FINANCE_OVERVIEW = "FINANCE_OVERVIEW"
// NEW WIDGET TYPE
const val GOAL_TRACKER = "GOAL_TRACKER"
}Step 2: Update Default Configuration
File: /server/src/main/kotlin/com/esteban/ruano/service/DashboardConfigurationService.kt
Add to default widgets:
fun createDefaultConfiguration(platform: String): DashboardConfigurationDTO {
return DashboardConfigurationDTO(
widgets = listOf(
// ... existing widgets ...
DashboardWidgetDTO(
id = "goal-tracker",
type = WidgetTypes.GOAL_TRACKER,
title = "Goal Progress",
size = "MEDIUM",
enabled = true,
order = 3,
platforms = listOf("DESKTOP", "WEB"),
config = mapOf(
"showProgress" to "true",
"maxGoals" to "5"
)
)
),
gridColumns = 3,
platform = platform
)
}Step 3: Handle in Frontend
Frontend code would read the widget configuration and render accordingly based on type.
Implementing New Frequency Types
Use Case: Add support for “BI_WEEKLY” or “QUARTERLY” habit frequencies.
Step 1: Add Frequency Constant
File: /shared/src/commonMain/kotlin/models/habits/HabitFrequency.kt (if exists)
enum class HabitFrequency {
DAILY,
WEEKLY,
MONTHLY,
YEARLY,
BI_WEEKLY, // NEW
QUARTERLY // NEW
}Step 2: Update Next Habit Calculation
File: /server/src/main/kotlin/com/esteban/ruano/service/DashboardService.kt
Location: Line 315-352 (frequency switch in getNextHabit)
Add Cases:
val nextOccurrence = when (habit.frequency.uppercase()) {
"DAILY" -> { /* existing */ }
"WEEKLY" -> { /* existing */ }
"MONTHLY" -> { /* existing */ }
"YEARLY" -> { /* existing */ }
// NEW: Bi-weekly (every 2 weeks)
"BI_WEEKLY" -> {
val targetDayOfWeek = baseDate.dayOfWeek
val daysSinceBase = ChronoUnit.DAYS.between(baseDate, today).toInt()
val weeksSinceBase = daysSinceBase / 7
// Calculate next occurrence (every 2 weeks)
if (weeksSinceBase % 2 == 0 && today.dayOfWeek == targetDayOfWeek) {
today
} else {
var nextDate = today
while (true) {
nextDate = nextDate.plus(DatePeriod(days = 1))
val newDaysSince = ChronoUnit.DAYS.between(baseDate, nextDate).toInt()
val newWeeksSince = newDaysSince / 7
if (newWeeksSince % 2 == 0 && nextDate.dayOfWeek == targetDayOfWeek) {
break
}
}
nextDate
}
}
// NEW: Quarterly (every 3 months)
"QUARTERLY" -> {
val targetDayOfMonth = baseDate.dayOfMonth
val monthsSinceBase = ChronoUnit.MONTHS.between(baseDate, today).toInt()
if (monthsSinceBase % 3 == 0 && today.dayOfMonth == targetDayOfMonth) {
today
} else {
var nextMonth = today.month.value
var nextYear = today.year
while (true) {
nextMonth++
if (nextMonth > 12) {
nextMonth = 1
nextYear++
}
val testDate = LocalDate(nextYear, nextMonth, 1)
val testMonthsSince = ChronoUnit.MONTHS.between(baseDate, testDate).toInt()
if (testMonthsSince % 3 == 0) {
return LocalDate(nextYear, nextMonth, targetDayOfMonth)
}
}
}
}
else -> baseDate
}Step 3: Update Overdue Detection
File: Same file, location: Line 538-643 (frequency switch in getOverdueHabitsList)
Add Cases:
val shouldBeOverdue = when (habit.frequency.uppercase()) {
"DAILY" -> { /* existing */ }
"WEEKLY" -> { /* existing */ }
"MONTHLY" -> { /* existing */ }
"YEARLY" -> { /* existing */ }
// NEW: Bi-weekly overdue logic
"BI_WEEKLY" -> {
val targetDayOfWeek = baseDate.dayOfWeek
val daysSinceBase = ChronoUnit.DAYS.between(baseDate, today).toInt()
val weeksSinceBase = daysSinceBase / 7
// Check if we're in a bi-weekly cycle
if (weeksSinceBase % 2 == 0 && today.dayOfWeek == targetDayOfWeek) {
currentTime > baseTime
} else if (weeksSinceBase % 2 == 1) {
// We're in an off-week, check if last occurrence was missed
val lastOccurrenceWeeksAgo = if (today.dayOfWeek.ordinal < targetDayOfWeek.ordinal) 2 else 1
val lastOccurrenceDate = today.minus(DatePeriod(days = lastOccurrenceWeeksAgo * 7 + (today.dayOfWeek.ordinal - targetDayOfWeek.ordinal)))
val lastOccurrenceDateTime = LocalDateTime(lastOccurrenceDate, baseTime)
now > lastOccurrenceDateTime
} else {
false
}
}
// NEW: Quarterly overdue logic
"QUARTERLY" -> {
// Similar to monthly but every 3 months
val targetDayOfMonth = baseDate.dayOfMonth
val monthsSinceBase = ChronoUnit.MONTHS.between(baseDate, today).toInt()
if (monthsSinceBase % 3 == 0 && today.dayOfMonth == targetDayOfMonth) {
currentTime > baseTime
} else if (monthsSinceBase % 3 > 0) {
// Calculate last quarterly occurrence
val lastQuarterMonthsAgo = monthsSinceBase % 3
val lastOccurrenceDate = today.minus(DatePeriod(months = lastQuarterMonthsAgo))
.let { LocalDate(it.year, it.month, targetDayOfMonth) }
val lastOccurrenceDateTime = LocalDateTime(lastOccurrenceDate, baseTime)
now > lastOccurrenceDateTime
} else {
false
}
}
else -> { /* existing */ }
}Step 4: Add Tests
File: /server/src/test/kotlin/com/esteban/ruano/service/DashboardServiceTest.kt
@Test
fun `bi-weekly habit shows as overdue after 2 weeks`() {
val habit = createMockHabit(
frequency = "BI_WEEKLY",
baseDateTime = "2024-01-01T10:00:00", // Monday
done = false
)
// 2 weeks + 1 day later, same day of week, time passed
val now = "2024-01-15T11:00:00".toLocalDateTime() // Monday, time passed
val overdueHabits = service.getOverdueHabitsList(listOf(habit), now)
assertEquals(1, overdueHabits.size)
}
@Test
fun `quarterly habit calculates next occurrence correctly`() {
val habit = createMockHabit(
frequency = "QUARTERLY",
baseDateTime = "2024-01-15T10:00:00",
done = false
)
val now = "2024-02-16T09:00:00".toLocalDateTime()
val nextHabit = service.getNextHabit(listOf(habit), now)
assertNotNull(nextHabit)
assertEquals(habit.id, nextHabit.id)
}Debugging Overdue Calculation Issues
Use Case: Habit is incorrectly showing as overdue (or not overdue when it should be).
Step 1: Enable Debug Logging
Debug logging is already enabled in DashboardService.kt. Check server logs for output.
Look for:
=== OVERDUE HABITS DEBUG ===
Time: 2024-01-16T14:30:00, Total habits: 5
Today: 2024-01-16, Current time: 14:30
Checking: Morning workout (DAILY) - Base: 2024-01-16 07:00
DAILY: next occurrence: 2024-01-16T07:00, has passed: true
Final result for Morning workout: true
Overdue habits found: 1
- Morning workout (DAILY)
=== END OVERDUE HABITS ===Step 2: Verify Habit Data
Check the habit’s dateTime field in the database:
SELECT id, name, frequency, date_time, done
FROM habits
WHERE user_id = ? AND name = 'Morning workout';Ensure:
date_timeis not nullfrequencymatches expected valuedoneis false
Step 3: Manually Test Calculation
Create a unit test with the exact scenario:
@Test
fun `debug specific habit overdue calculation`() {
val habit = HabitDTO(
id = "test-id",
name = "Morning workout",
frequency = "DAILY",
dateTime = "2024-01-16T07:00:00",
done = false,
streak = 5
)
val now = "2024-01-16T14:30:00".toLocalDateTime()
val overdueHabits = dashboardService.getOverdueHabitsList(listOf(habit), now)
// Add breakpoint here to step through logic
println("Overdue count: ${overdueHabits.size}")
overdueHabits.forEach { println("Overdue: ${it.name}") }
}Step 4: Check Time Zones
Ensure server and client use same timezone:
// In DashboardService.kt
println("Server timezone: ${TimeZone.currentSystemDefault()}")
println("Current time: ${Clock.System.now()}")Optimizing Dashboard Query Performance
Use Case: Dashboard endpoint is slow with large datasets.
Strategy 1: Add Caching
File: Create /server/src/main/kotlin/com/esteban/ruano/service/DashboardCacheService.kt
class DashboardCacheService {
private val cache = ConcurrentHashMap<String, CachedDashboard>()
private val cacheDuration = 5.minutes
data class CachedDashboard(
val data: DashboardResponseDTO,
val timestamp: Instant
)
fun get(userId: Int, dateTime: String): DashboardResponseDTO? {
val key = "$userId:$dateTime"
val cached = cache[key] ?: return null
if (Clock.System.now() - cached.timestamp > cacheDuration) {
cache.remove(key)
return null
}
return cached.data
}
fun put(userId: Int, dateTime: String, data: DashboardResponseDTO) {
val key = "$userId:$dateTime"
cache[key] = CachedDashboard(data, Clock.System.now())
}
}Update DashboardService:
class DashboardService(
private val taskService: TaskService,
// ... other services ...
private val cacheService: DashboardCacheService
) {
fun getDashboardData(userId: Int, dateTime: String): DashboardResponseDTO {
// Check cache first
cacheService.get(userId, dateTime)?.let { return it }
// ... existing logic ...
val result = DashboardResponseDTO(/* ... */)
// Cache result
cacheService.put(userId, dateTime, result)
return result
}
}Strategy 2: Parallelize Service Calls
Use Kotlin Coroutines:
suspend fun getDashboardData(userId: Int, dateTime: String): DashboardResponseDTO = coroutineScope {
val currentDateTime = dateTime.toLocalDateTime()
val today = currentDateTime.date
val weekStart = today.minus(DatePeriod(days = today.dayOfWeek.ordinal))
val weekEnd = weekStart.plus(DatePeriod(days = 6))
// Fetch all data in parallel
val tasksDeferred = async { taskService.fetchAllByDateRange(userId, "", weekStart, weekEnd, 100, 0) }
val habitsDeferred = async { habitService.fetchAllByDateRange(userId, "", weekStart, weekEnd, 100, 0) }
val transactionsDeferred = async { transactionService.getTransactionsByUser(userId, limit = 3) }
val balanceDeferred = async { accountService.getTotalBalance(userId) }
// ... more async calls ...
val tasks = tasksDeferred.await()
val habits = habitsDeferred.await()
val transactions = transactionsDeferred.await()
val balance = balanceDeferred.await()
// ... rest of logic ...
}Strategy 3: Add Database Indexes
-- Index on track tables for date range queries
CREATE INDEX idx_task_tracks_done_datetime ON task_tracks(done_date_time);
CREATE INDEX idx_habit_tracks_done_datetime ON habit_tracks(done_date_time);
CREATE INDEX idx_recipe_tracks_consumed_datetime ON recipe_tracks(consumed_date_time);
-- Composite index for user-specific queries
CREATE INDEX idx_tasks_user_date ON tasks(user_id, due_date_time);
CREATE INDEX idx_habits_user_date ON habits(user_id, date_time);Strategy 4: Reduce Data Fetched
Add pagination support:
// Instead of fetching 100 items
val tasks = taskService.fetchAllByDateRange(userId, "", weekStart, weekEnd, 100, 0)
// Fetch only what's needed
val tasks = taskService.fetchAllByDateRange(userId, "", weekStart, weekEnd, 20, 0)Adding New Platform Support
Use Case: Add support for a new platform like “TABLET” or “WATCH”.
Step 1: Define Platform Constant
File: /shared/src/commonMain/kotlin/models/dashboard/Platforms.kt (if exists)
object Platforms {
const val ANDROID = "ANDROID"
const val DESKTOP = "DESKTOP"
const val WEB = "WEB"
const val IOS = "IOS"
const val TABLET = "TABLET" // NEW
}Step 2: No Server Changes Needed
The server already supports any platform string. Configuration is stored per (userId, platform) pair.
Step 3: Create Default Configuration
File: Frontend configuration initialization
// When user first accesses dashboard from tablet
val defaultConfig = DashboardConfigurationDTO(
widgets = listOf(
DashboardWidgetDTO(
id = "quick-overview",
type = "COMPACT_VIEW",
title = "Quick Overview",
size = "SMALL",
enabled = true,
order = 0,
platforms = listOf("TABLET"),
config = mapOf("compact" to "true")
)
),
gridColumns = 2, // Smaller grid for tablets
platform = "TABLET"
)
// Save to server
dashboardApi.saveConfiguration(defaultConfig)Testing Dashboard Functionality
Unit Testing
Test Next Task Selection:
@Test
fun `returns highest priority overdue task`() {
val tasks = listOf(
createTask(priority = 3, dueDate = yesterday),
createTask(priority = 5, dueDate = yesterday),
createTask(priority = 4, dueDate = today)
)
val result = dashboardService.getNextTask(tasks, now)
assertEquals(5, result?.priority)
}Test Next Habit Calculation:
@Test
fun `returns next habit with future time today`() {
val habits = listOf(
createHabit(frequency = "DAILY", baseTime = "18:00"),
createHabit(frequency = "DAILY", baseTime = "20:00")
)
val now = "2024-01-16T17:00:00".toLocalDateTime()
val result = dashboardService.getNextHabit(habits, now)
assertEquals("18:00", result?.dateTime?.substring(11, 16))
}Integration Testing
Test Full Dashboard Endpoint:
@Test
fun `dashboard endpoint returns complete data`() = testApplication {
application {
configureSerialization()
configureRouting()
}
val response = client.get("/api/v1/dashboard?dateTime=2024-01-16T14:30:00") {
header("Authorization", "Bearer $validToken")
}
assertEquals(HttpStatusCode.OK, response.status)
val dashboard = response.body<DashboardResponseDTO>()
assertNotNull(dashboard.taskStats)
assertNotNull(dashboard.habitStats)
}Manual Testing
Using cURL:
# Get dashboard data
curl -X GET "http://localhost:8080/api/v1/dashboard?dateTime=2024-01-16T14:30:00" \
-H "Authorization: Bearer YOUR_TOKEN"
# Get configuration
curl -X GET "http://localhost:8080/api/v1/dashboard/configuration?platform=DESKTOP" \
-H "Authorization: Bearer YOUR_TOKEN"
# Save configuration
curl -X POST "http://localhost:8080/api/v1/dashboard/configuration" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"configuration":{"widgets":[],"gridColumns":3,"platform":"DESKTOP"}}'