refactor: unify docker deployment and restructure frontend architecture

This commit implements a unified Docker deployment strategy, moving from separate frontend and backend images to a single, multi-stage build image containing both services. It also introduces a major reorganization of the frontend directory structure and simplifies the environment configuration.

Key changes:
- **Deployment**: Added a multi-stage `Dockerfile` and `docker-entrypoint.sh` to package the Go backend and Nginx-served frontend into a single container.
- **CI/CD**: Updated GitHub Actions workflows (`ci-cd.yml`, `release.yml`) to build and push the new unified image instead of separate ones.
- **Frontend Refactor**: Reorganized `frontend/src/pages` into a domain-driven directory structure (e.g., `auth/`, `admin/`, `content/`, `communication/`, `productivity/`, `settings/`, `misc/`).
- **Configuration**: Simplified `.env.example` and updated `docker-compose.yml` to reflect the unified service model and single host port.
- **Cleanup**: Removed deprecated `docker-compose.demo.yml`, `docker-compose.prod.yml`, and various unused frontend components and services.
- **Backend**: Refactored configuration loading to use exported `GetDurationEnv` for better consistency.
This commit is contained in:
Tomas Dvorak
2026-05-10 10:48:41 +02:00
parent c6a99c7e21
commit 6c448b336a
71 changed files with 135367 additions and 4481 deletions
@@ -0,0 +1,204 @@
import { createSignal, createEffect, onMount } from 'solid-js';
import { Timer } from '@/components/Timer';
import { TimeEntriesList } from '@/components/TimeEntriesList';
import { type TimeEntry, timeEntriesApi, demoTimeEntriesApi } from '@/lib/api';
import { IconClock, IconActivity, IconCurrencyDollar } from '@tabler/icons-solidjs';
import { isDemoMode } from '@/lib/demo-mode';
import { useHaptics } from '@/lib/haptics';
export const TimeTracking = () => {
const haptics = useHaptics();
const [refreshTrigger, setRefreshTrigger] = createSignal(0);
const [timeEntries, setTimeEntries] = createSignal<TimeEntry[]>([]);
const [loading, setLoading] = createSignal(true);
const [currentRunningEntry, setCurrentRunningEntry] = createSignal<TimeEntry | null>(null);
const [currentElapsedSeconds, setCurrentElapsedSeconds] = createSignal(0);
// Use appropriate API based on demo mode
const getApi = () => isDemoMode() ? demoTimeEntriesApi : timeEntriesApi;
const loadTimeEntries = async () => {
try {
setLoading(true);
const response = await getApi().getAll();
// Handle different response formats
let entries: TimeEntry[] = [];
if (response && response.time_entries) {
entries = response.time_entries;
} else if (response && Array.isArray(response)) {
entries = response;
} else {
console.warn('Unexpected response format:', response);
entries = [];
}
setTimeEntries(entries);
} catch (err) {
console.error('Failed to load time entries:', err);
setTimeEntries([]); // Ensure empty array on error
} finally {
setLoading(false);
}
};
// Calculate today's statistics including real-time running timer
const getTodayStats = () => {
const entries = timeEntries() || [];
const today = new Date().toDateString();
const todayEntries = entries.filter(entry =>
new Date(entry.start_time).toDateString() === today
);
// Start with completed entries
let totalSeconds = todayEntries.reduce((sum, entry) =>
sum + (entry.duration || 0), 0
);
let billableSeconds = todayEntries.reduce((sum, entry) =>
sum + (entry.duration || 0), 0
);
let totalBillableAmount = todayEntries.reduce((sum, entry) => {
if (entry.duration && entry.hourly_rate && entry.billable) {
return sum + (entry.duration / 3600 * entry.hourly_rate);
}
return sum;
}, 0);
// Add real-time data from currently running timer
const runningEntry = currentRunningEntry();
if (runningEntry && new Date(runningEntry.start_time).toDateString() === today) {
const elapsed = currentElapsedSeconds();
totalSeconds += elapsed;
if (runningEntry.billable) {
billableSeconds += elapsed;
if (runningEntry.hourly_rate) {
totalBillableAmount += (elapsed / 3600 * runningEntry.hourly_rate);
}
}
}
const runningCount = todayEntries.filter(entry => entry.is_running).length +
(runningEntry ? 1 : 0);
return {
totalSeconds,
totalEntries: todayEntries.length + (runningEntry ? 1 : 0),
billableSeconds,
totalBillableAmount,
runningCount
};
};
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
};
const formatAmount = (amount: number): string => {
return `$${amount.toFixed(2)}`;
};
const handleTimeEntryCreated = (newEntry: TimeEntry) => {
setTimeEntries(prev => [newEntry, ...prev]);
setRefreshTrigger(prev => prev + 1);
haptics.success(); // Success feedback for time entry creation
};
// Handle real-time timer updates
const handleTimerUpdate = (entry: TimeEntry | null, elapsedSeconds: number) => {
setCurrentRunningEntry(entry);
setCurrentElapsedSeconds(elapsedSeconds);
};
// Load time entries on mount and when refresh trigger changes
onMount(() => {
loadTimeEntries();
});
createEffect(() => {
if (refreshTrigger() > 0) {
loadTimeEntries();
}
});
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
{/* Simple loading indicator */}
{loading() && (
<div class="text-center text-sm text-muted-foreground py-2">
Loading time entries...
</div>
)}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Timer Component */}
<div>
<Timer
onTimeEntryCreated={handleTimeEntryCreated}
onTimerUpdate={handleTimerUpdate}
/>
</div>
{/* Time Stats - Standardized Design */}
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-4">Today's Overview</h2>
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconClock class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{formatTime(getTodayStats().totalSeconds)}</p>
<p class="text-sm text-muted-foreground">Total Time Today</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconActivity class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{getTodayStats().totalEntries}</p>
<p class="text-sm text-muted-foreground">Entries Today</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconCurrencyDollar class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{formatAmount(getTodayStats().totalBillableAmount)}</p>
<p class="text-sm text-muted-foreground">
Billable Today
{currentRunningEntry() && currentRunningEntry()?.billable && (
<span class="ml-1 text-green-600 dark:text-green-400">
Live
</span>
)}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
<IconActivity class="size-5 text-primary" />
</div>
<div>
<p class="text-2xl font-light">{getTodayStats().runningCount}</p>
<p class="text-sm text-muted-foreground">Running Timers</p>
</div>
</div>
</div>
</div>
</div>
{/* Time Entries List */}
<div>
<TimeEntriesList refreshTrigger={refreshTrigger()} />
</div>
</div>
);
};