feat: add profitability tab and favorites strip
Reports view now has Hours/Profitability tabs with per-project revenue table. Timer view shows favorites strip for quick project selection and a Save as Favorite button next to the description input.
This commit is contained in:
@@ -32,6 +32,32 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Buttons -->
|
||||||
|
<div class="flex items-center gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'hours'"
|
||||||
|
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||||
|
:class="activeTab === 'hours' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<BarChart3 class="w-3.5 h-3.5" :stroke-width="1.5" />
|
||||||
|
Hours
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'profitability'; if (profitabilityData.length === 0 && startDate && endDate) fetchProfitability()"
|
||||||
|
class="px-4 py-2 text-[0.75rem] font-medium rounded-lg transition-colors"
|
||||||
|
:class="activeTab === 'profitability' ? 'bg-accent text-bg-base' : 'text-text-secondary hover:text-text-primary'"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<DollarSign class="w-3.5 h-3.5" :stroke-width="1.5" />
|
||||||
|
Profitability
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hours Tab -->
|
||||||
|
<template v-if="activeTab === 'hours'">
|
||||||
<!-- Summary Stats — pure typography -->
|
<!-- Summary Stats — pure typography -->
|
||||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -99,6 +125,78 @@
|
|||||||
No data for selected date range
|
No data for selected date range
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Profitability Tab -->
|
||||||
|
<template v-if="activeTab === 'profitability'">
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-cols-3 gap-6 mb-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Revenue</p>
|
||||||
|
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitTotalRevenue) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Total Hours</p>
|
||||||
|
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ profitTotalHours.toFixed(1) }}h</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1">Avg Hourly Rate</p>
|
||||||
|
<p class="text-[1.25rem] font-[family-name:var(--font-heading)] text-accent-text font-medium">{{ formatCurrency(profitAvgRate) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue Chart -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Revenue by Project</h2>
|
||||||
|
<div class="h-64">
|
||||||
|
<Bar v-if="profitChartData" :data="profitChartData" :options="profitChartOptions" />
|
||||||
|
<div v-else class="flex flex-col items-center justify-center h-full">
|
||||||
|
<DollarSign class="w-12 h-12 text-text-tertiary" :stroke-width="1.5" />
|
||||||
|
<p class="text-sm text-text-secondary mt-3">Generate a report to see profitability data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Table -->
|
||||||
|
<div>
|
||||||
|
<h2 class="text-[1.25rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-4">Per-Project Breakdown</h2>
|
||||||
|
|
||||||
|
<div v-if="profitabilityData.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-border-subtle">
|
||||||
|
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Project</th>
|
||||||
|
<th class="text-left text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Client</th>
|
||||||
|
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Hours</th>
|
||||||
|
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Rate</th>
|
||||||
|
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2 pr-4">Revenue</th>
|
||||||
|
<th class="text-right text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] py-2">Budget %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in profitabilityData"
|
||||||
|
:key="row.project_name"
|
||||||
|
class="border-b border-border-subtle last:border-0"
|
||||||
|
>
|
||||||
|
<td class="py-3 pr-4 text-[0.8125rem] text-text-primary">{{ row.project_name }}</td>
|
||||||
|
<td class="py-3 pr-4 text-[0.75rem] text-text-secondary">{{ row.client_name || '—' }}</td>
|
||||||
|
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-primary">{{ row.total_hours.toFixed(1) }}h</td>
|
||||||
|
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-text-secondary">{{ formatCurrency(row.hourly_rate) }}</td>
|
||||||
|
<td class="py-3 pr-4 text-right text-[0.75rem] font-mono text-accent-text">{{ formatCurrency(row.revenue) }}</td>
|
||||||
|
<td class="py-3 text-right text-[0.75rem] font-mono" :class="row.budget_used_pct != null && row.budget_used_pct > 100 ? 'text-status-error' : 'text-text-secondary'">
|
||||||
|
{{ row.budget_used_pct != null ? row.budget_used_pct.toFixed(0) + '%' : '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-[0.75rem] text-text-tertiary py-8 text-center">
|
||||||
|
No data for selected date range
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -106,7 +204,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { Bar } from 'vue-chartjs'
|
import { Bar } from 'vue-chartjs'
|
||||||
import { BarChart3 } from 'lucide-vue-next'
|
import { BarChart3, DollarSign } from 'lucide-vue-next'
|
||||||
import AppDatePicker from '../components/AppDatePicker.vue'
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import {
|
import {
|
||||||
@@ -140,6 +238,19 @@ interface ReportData {
|
|||||||
byProject: ProjectReport[]
|
byProject: ProjectReport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProfitabilityRow {
|
||||||
|
project_name: string
|
||||||
|
client_name: string | null
|
||||||
|
total_hours: number
|
||||||
|
hourly_rate: number
|
||||||
|
revenue: number
|
||||||
|
budget_hours: number | null
|
||||||
|
budget_used_pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref<'hours' | 'profitability'>('hours')
|
||||||
|
const profitabilityData = ref<ProfitabilityRow[]>([])
|
||||||
|
|
||||||
const startDate = ref('')
|
const startDate = ref('')
|
||||||
const endDate = ref('')
|
const endDate = ref('')
|
||||||
const reportData = ref<ReportData>({
|
const reportData = ref<ReportData>({
|
||||||
@@ -275,12 +386,103 @@ async function fetchReport() {
|
|||||||
totalEarnings,
|
totalEarnings,
|
||||||
byProject: data.byProject as ProjectReport[]
|
byProject: data.byProject as ProjectReport[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also fetch profitability if on that tab
|
||||||
|
if (activeTab.value === 'profitability') {
|
||||||
|
await fetchProfitability()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch report:', error)
|
console.error('Failed to fetch report:', error)
|
||||||
toastStore.error('Failed to generate report')
|
toastStore.error('Failed to generate report')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profitability summary stats
|
||||||
|
const profitTotalRevenue = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.revenue, 0))
|
||||||
|
const profitTotalHours = computed(() => profitabilityData.value.reduce((sum, r) => sum + r.total_hours, 0))
|
||||||
|
const profitAvgRate = computed(() => profitTotalHours.value > 0 ? profitTotalRevenue.value / profitTotalHours.value : 0)
|
||||||
|
|
||||||
|
// Profitability chart data
|
||||||
|
const profitChartData = computed(() => {
|
||||||
|
if (profitabilityData.value.length === 0) return null
|
||||||
|
|
||||||
|
const labels = profitabilityData.value.map(r => r.project_name)
|
||||||
|
const data = profitabilityData.value.map(r => r.revenue)
|
||||||
|
const colors = profitabilityData.value.map((r) => {
|
||||||
|
const project = projectsStore.projects.find(p => p.name === r.project_name)
|
||||||
|
return project?.color || '#6B7280'
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Revenue',
|
||||||
|
data,
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderRadius: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Profitability chart options
|
||||||
|
const profitChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: 'y' as const,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: { raw: unknown }) => {
|
||||||
|
const val = context.raw as number
|
||||||
|
return formatCurrency(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: '#2E2E2A'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#5A5A54'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#5A5A54'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profitability data
|
||||||
|
async function fetchProfitability() {
|
||||||
|
if (!startDate.value || !endDate.value) {
|
||||||
|
toastStore.info('Please select a date range')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
profitabilityData.value = await invoke<ProfitabilityRow[]>('get_profitability_report', {
|
||||||
|
startDate: startDate.value,
|
||||||
|
endDate: endDate.value
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profitability report:', error)
|
||||||
|
toastStore.error('Failed to generate profitability report')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Export to CSV
|
// Export to CSV
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
|
if (!reportData.value.byProject || reportData.value.byProject.length === 0) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<!-- Hero timer display -->
|
<!-- Hero timer display -->
|
||||||
<div class="text-center pt-4 pb-8">
|
<div class="text-center pt-4 pb-8">
|
||||||
|
<div class="relative inline-block">
|
||||||
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2">
|
<p class="text-[3.5rem] font-medium font-[family-name:var(--font-heading)] tracking-tighter mb-2">
|
||||||
<span class="text-text-primary">{{ timerParts.hours }}</span>
|
<span class="text-text-primary">{{ timerParts.hours }}</span>
|
||||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||||
@@ -9,6 +10,15 @@
|
|||||||
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
<span :class="timerStore.isRunning ? 'text-accent-text animate-pulse-colon' : 'text-text-primary'">:</span>
|
||||||
<span class="text-text-primary">{{ timerParts.seconds }}</span>
|
<span class="text-text-primary">{{ timerParts.seconds }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="timerStore.isRunning"
|
||||||
|
@click="openMiniTimer"
|
||||||
|
class="absolute -right-8 top-1/2 -translate-y-1/2 p-2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||||
|
title="Pop out mini timer"
|
||||||
|
>
|
||||||
|
<ExternalLink class="w-4 h-4" :stroke-width="1.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Paused indicator -->
|
<!-- Paused indicator -->
|
||||||
<p
|
<p
|
||||||
@@ -29,6 +39,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorites strip -->
|
||||||
|
<div v-if="favorites.length > 0" class="max-w-[36rem] mx-auto mb-4">
|
||||||
|
<div class="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
|
<button
|
||||||
|
v-for="fav in favorites"
|
||||||
|
:key="fav.id"
|
||||||
|
@click="applyFavorite(fav)"
|
||||||
|
:disabled="!timerStore.isStopped"
|
||||||
|
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border-subtle text-[0.6875rem] text-text-secondary hover:text-text-primary hover:border-border-visible transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: getProjectColor(fav.project_id) }" />
|
||||||
|
{{ getProjectName(fav.project_id) }}
|
||||||
|
<span v-if="fav.description" class="text-text-tertiary">· {{ fav.description }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Inputs -->
|
<!-- Inputs -->
|
||||||
<div class="max-w-[36rem] mx-auto mb-8">
|
<div class="max-w-[36rem] mx-auto mb-8">
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
@@ -59,13 +86,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="description"
|
v-model="description"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="!timerStore.isStopped"
|
:disabled="!timerStore.isStopped"
|
||||||
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
class="flex-1 px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="timerStore.isStopped && selectedProject"
|
||||||
|
@click="saveAsFavorite"
|
||||||
|
class="p-2 text-text-tertiary hover:text-accent-text transition-colors"
|
||||||
|
title="Save as favorite"
|
||||||
|
>
|
||||||
|
<Star class="w-4 h-4" :stroke-width="1.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,17 +180,21 @@ import { useProjectsStore, type Task } from '../stores/projects'
|
|||||||
import { useEntriesStore } from '../stores/entries'
|
import { useEntriesStore } from '../stores/entries'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import { Timer as TimerIcon, RotateCcw } from 'lucide-vue-next'
|
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star } from 'lucide-vue-next'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import AppSelect from '../components/AppSelect.vue'
|
import AppSelect from '../components/AppSelect.vue'
|
||||||
import IdlePromptDialog from '../components/IdlePromptDialog.vue'
|
import IdlePromptDialog from '../components/IdlePromptDialog.vue'
|
||||||
import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue'
|
import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue'
|
||||||
import { formatDateTime } from '../utils/locale'
|
import { formatDateTime } from '../utils/locale'
|
||||||
|
import { useFavoritesStore, type Favorite } from '../stores/favorites'
|
||||||
|
|
||||||
const timerStore = useTimerStore()
|
const timerStore = useTimerStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
const entriesStore = useEntriesStore()
|
const entriesStore = useEntriesStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
|
const favoritesStore = useFavoritesStore()
|
||||||
|
const favorites = computed(() => favoritesStore.favorites)
|
||||||
|
|
||||||
// Local state for inputs
|
// Local state for inputs
|
||||||
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
|
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
|
||||||
@@ -253,6 +294,11 @@ watch(() => timerStore.timerState, async (newState, oldState) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Open mini timer pop-out
|
||||||
|
async function openMiniTimer() {
|
||||||
|
await invoke('open_mini_timer')
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle timer
|
// Toggle timer
|
||||||
function toggleTimer() {
|
function toggleTimer() {
|
||||||
if (timerStore.isStopped) {
|
if (timerStore.isStopped) {
|
||||||
@@ -298,6 +344,24 @@ function repeatEntry(entry: { project_id: number; task_id?: number; description?
|
|||||||
description.value = entry.description || ''
|
description.value = entry.description || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply a favorite to the inputs
|
||||||
|
function applyFavorite(fav: Favorite) {
|
||||||
|
selectedProject.value = fav.project_id
|
||||||
|
selectedTask.value = fav.task_id || null
|
||||||
|
description.value = fav.description || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current inputs as a favorite
|
||||||
|
async function saveAsFavorite() {
|
||||||
|
if (!selectedProject.value) return
|
||||||
|
await favoritesStore.createFavorite({
|
||||||
|
project_id: selectedProject.value,
|
||||||
|
task_id: selectedTask.value || undefined,
|
||||||
|
description: description.value || undefined,
|
||||||
|
sort_order: favoritesStore.favorites.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Get project name by ID
|
// Get project name by ID
|
||||||
function getProjectName(projectId: number): string {
|
function getProjectName(projectId: number): string {
|
||||||
const project = projectsStore.projects.find(p => p.id === projectId)
|
const project = projectsStore.projects.find(p => p.id === projectId)
|
||||||
@@ -325,7 +389,8 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
projectsStore.fetchProjects(),
|
projectsStore.fetchProjects(),
|
||||||
entriesStore.fetchEntries(),
|
entriesStore.fetchEntries(),
|
||||||
settingsStore.fetchSettings()
|
settingsStore.fetchSettings(),
|
||||||
|
favoritesStore.fetchFavorites()
|
||||||
])
|
])
|
||||||
|
|
||||||
// Restore timer state
|
// Restore timer state
|
||||||
|
|||||||
Reference in New Issue
Block a user