Files
timetracker/ios/TimeTracker/TimeTracker/Features/Dashboard/DashboardView.swift
Simon Franken ba4765b8a2 Rebuild iOS app: calendar entries, overtime dashboard, settings tab, full CRUD
- Replace 5-tab layout with 4 tabs: Dashboard, Timer, Entries, Settings
- Dashboard: add Work Time Balance section using /client-targets API, showing
  per-client weekly progress bar, overtime/undertime label and expandable week breakdown
- Time Entries: replace flat list with UICalendarView month grid; tap a day to see
  that day's entries; add filter sheet (date range, project, client); new
  TimeEntryDetailSheet for creating and editing entries; duration shown as Xh Ymin
- Settings tab: user info header, navigation to Clients and Projects, logout button
- ClientsListView: list with NavigationLink to ClientDetailView
- ClientDetailView: inline client editing + full work time target CRUD (create,
  edit, delete target; add/delete balance corrections with date, hours, description)
- ProjectsListView: grouped by client, NavigationLink to ProjectDetailView
- ProjectDetailView: edit name, description, colour, client assignment
- Add ClientTarget, WeekBalance, BalanceCorrection models and APIEndpoints for
  /client-targets routes
- Update TimeInterval formatter: add formattedShortDuration (Xh Ymin / Xmin / <1min)
  used throughout app; keep formattedDuration for live timer display
2026-02-21 13:51:41 +01:00

311 lines
11 KiB
Swift

import SwiftUI
struct DashboardView: View {
@StateObject private var viewModel = DashboardViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.statistics == nil && viewModel.recentEntries.isEmpty {
LoadingView()
} else {
scrollContent
}
}
.navigationTitle("Dashboard")
.refreshable { await viewModel.loadData() }
.task { await viewModel.loadData() }
}
}
// MARK: - Main scroll content
private var scrollContent: some View {
ScrollView {
VStack(spacing: 24) {
timerCard
if let stats = viewModel.statistics { weeklyStatsSection(stats) }
if !viewModel.clientTargets.isEmpty { workBalanceSection }
recentEntriesSection
}
.padding()
}
}
// MARK: - Active Timer Card
private var timerCard: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
if let timer = viewModel.activeTimer {
Text("Timer Running")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(viewModel.elapsedTime.formattedDuration)
.font(.system(size: 32, weight: .medium, design: .monospaced))
if let project = timer.project {
ProjectColorBadge(color: project.color, name: project.name)
}
} else {
Text("No Active Timer")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Start tracking to see your time")
.font(.headline)
}
}
Spacer()
Image(systemName: "timer")
.font(.title)
.foregroundStyle(viewModel.activeTimer != nil ? .green : .secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
// MARK: - Weekly Stats
private func weeklyStatsSection(_ stats: TimeStatistics) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("This Week")
.font(.headline)
HStack(spacing: 12) {
StatCard(
title: "Hours Tracked",
value: TimeInterval(stats.totalSeconds).formattedShortDuration,
icon: "clock.fill",
color: .blue
)
StatCard(
title: "Entries",
value: "\(stats.entryCount)",
icon: "list.bullet",
color: .green
)
}
if !stats.byProject.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("By Project")
.font(.subheadline)
.foregroundStyle(.secondary)
ForEach(stats.byProject.prefix(5)) { projectStat in
HStack {
if let color = projectStat.projectColor {
ProjectColorDot(color: color)
}
Text(projectStat.projectName)
.font(.subheadline)
Spacer()
Text(TimeInterval(projectStat.totalSeconds).formattedShortDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
}
// MARK: - Work Balance Section
private var workBalanceSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Work Time Balance")
.font(.headline)
ForEach(viewModel.clientTargets) { target in
WorkBalanceCard(target: target)
}
}
}
// MARK: - Recent Entries
private var recentEntriesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Recent Entries")
.font(.headline)
if viewModel.recentEntries.isEmpty {
Text("No entries yet")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
VStack(spacing: 0) {
ForEach(Array(viewModel.recentEntries.enumerated()), id: \.element.id) { index, entry in
HStack {
ProjectColorDot(color: entry.project.color, size: 10)
VStack(alignment: .leading, spacing: 2) {
Text(entry.project.name)
.font(.subheadline)
Text(formatDate(entry.startTime))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(entry.duration.formattedShortDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
if index < viewModel.recentEntries.count - 1 {
Divider()
}
}
}
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
private func formatDate(_ isoString: String) -> String {
guard let date = Date.fromISO8601(isoString) else { return "" }
return date.formattedDateTime()
}
}
// MARK: - Work Balance Card
struct WorkBalanceCard: View {
let target: ClientTarget
@State private var expanded = false
private var totalBalance: TimeInterval { TimeInterval(target.totalBalanceSeconds) }
private var currentWeekTracked: TimeInterval { TimeInterval(target.currentWeekTrackedSeconds) }
private var currentWeekTarget: TimeInterval { TimeInterval(target.currentWeekTargetSeconds) }
private var balanceColor: Color {
if target.totalBalanceSeconds >= 0 { return .green }
return .red
}
private var balanceLabel: String {
let abs = abs(totalBalance)
return target.totalBalanceSeconds >= 0
? "+\(abs.formattedShortDuration) overtime"
: "-\(abs.formattedShortDuration) undertime"
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header row
HStack {
Text(target.clientName)
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
Text(balanceLabel)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(balanceColor)
}
// This-week progress
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("This week")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(currentWeekTracked.formattedShortDuration) / \(currentWeekTarget.formattedShortDuration)")
.font(.caption)
.foregroundStyle(.secondary)
}
if currentWeekTarget > 0 {
ProgressView(value: min(currentWeekTracked / currentWeekTarget, 1.0))
.tint(currentWeekTracked >= currentWeekTarget ? .green : .blue)
}
}
// Weekly breakdown (expandable)
if !target.weeks.isEmpty {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack(spacing: 4) {
Text(expanded ? "Hide weeks" : "Show weeks")
.font(.caption)
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.caption2)
}
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
if expanded {
VStack(spacing: 6) {
ForEach(target.weeks.suffix(8).reversed()) { week in
WeekBalanceRow(week: week)
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
// MARK: - Week Balance Row
struct WeekBalanceRow: View {
let week: WeekBalance
private var balance: TimeInterval { TimeInterval(week.balanceSeconds) }
private var tracked: TimeInterval { TimeInterval(week.trackedSeconds) }
private var target: TimeInterval { TimeInterval(week.targetSeconds) }
private var balanceColor: Color { week.balanceSeconds >= 0 ? .green : .red }
var body: some View {
HStack {
Text(weekLabel)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(tracked.formattedShortDuration)
.font(.caption)
Text("/")
.font(.caption)
.foregroundStyle(.secondary)
Text(target.formattedShortDuration)
.font(.caption)
.foregroundStyle(.secondary)
Text(balanceText)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(balanceColor)
.frame(width: 70, alignment: .trailing)
}
}
private var weekLabel: String {
guard let date = parseDate(week.weekStart) else { return week.weekStart }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: date)
}
private var balanceText: String {
let abs = Swift.abs(balance)
return week.balanceSeconds >= 0 ? "+\(abs.formattedShortDuration)" : "-\(abs.formattedShortDuration)"
}
private func parseDate(_ string: String) -> Date? {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.date(from: string)
}
}