Merge pull request 'ios-rebuild' (#3) from ios-rebuild into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-02-23 16:57:45 +00:00
17 changed files with 2183 additions and 737 deletions

View File

@@ -25,6 +25,14 @@ enum APIEndpoint {
static let timer = "/timer"
static let timerStart = "/timer/start"
static let timerStop = "/timer/stop"
// Client Targets
static let clientTargets = "/client-targets"
static func clientTarget(id: String) -> String { "/client-targets/\(id)" }
static func clientTargetCorrections(targetId: String) -> String { "/client-targets/\(targetId)/corrections" }
static func clientTargetCorrection(targetId: String, correctionId: String) -> String {
"/client-targets/\(targetId)/corrections/\(correctionId)"
}
}
struct APIEndpoints {

View File

@@ -1,134 +1,3 @@
import SwiftUI
struct ClientsView: View {
@StateObject private var viewModel = ClientsViewModel()
@State private var showAddClient = false
@State private var clientToDelete: Client?
@State private var showDeleteConfirmation = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.clients.isEmpty {
LoadingView()
} else if let error = viewModel.error, viewModel.clients.isEmpty {
ErrorView(message: error) {
Task { await viewModel.loadClients() }
}
} else if viewModel.clients.isEmpty {
EmptyView(
icon: "person.2",
title: "No Clients",
message: "Create a client to organize your projects."
)
} else {
clientsList
}
}
.navigationTitle("Clients")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showAddClient = true
} label: {
Image(systemName: "plus")
}
}
}
.task {
await viewModel.loadClients()
}
.sheet(isPresented: $showAddClient) {
ClientFormView(onSave: { name, description in
Task {
await viewModel.createClient(name: name, description: description)
}
})
}
}
}
private var clientsList: some View {
List {
ForEach(viewModel.clients) { client in
ClientRow(client: client)
}
.onDelete { indexSet in
if let index = indexSet.first {
clientToDelete = viewModel.clients[index]
showDeleteConfirmation = true
}
}
}
.alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteClient(client)
}
}
} message: { client in
Text("This will permanently delete '\(client.name)' and all related projects and time entries. This action cannot be undone.")
}
.refreshable {
await viewModel.loadClients()
}
}
}
struct ClientRow: View {
let client: Client
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(client.name)
.font(.headline)
if let description = client.description {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
struct ClientFormView: View {
@Environment(\.dismiss) private var dismiss
let onSave: (String, String?) -> Void
@State private var name = ""
@State private var description = ""
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Client name", text: $name)
}
Section("Description (Optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle("New Client")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(name, description.isEmpty ? nil : description)
dismiss()
}
.disabled(name.isEmpty)
}
}
}
}
}
// ClientsView.swift replaced by Features/Settings/ClientsListView.swift
// This file is intentionally empty; ClientsViewModel is no longer used directly.
import Foundation

View File

@@ -5,79 +5,68 @@ struct DashboardView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Active Timer Card
timerCard
// Weekly Stats
if let stats = viewModel.statistics {
statsSection(stats)
Group {
if viewModel.isLoading && viewModel.statistics == nil && viewModel.recentEntries.isEmpty {
LoadingView()
} else {
scrollContent
}
}
.navigationTitle("Dashboard")
.refreshable { await viewModel.loadData() }
.task { await viewModel.loadData() }
}
}
// Recent Entries
// 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()
}
.navigationTitle("Dashboard")
.refreshable {
await viewModel.loadData()
}
.task {
await viewModel.loadData()
}
}
}
// MARK: - Active Timer Card
private var timerCard: some View {
VStack(spacing: 16) {
if let timer = viewModel.activeTimer {
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)
}
}
Spacer()
Image(systemName: "timer")
.font(.title)
.foregroundStyle(.green)
}
} else {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("No Active Timer")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Start tracking to see your time")
.font(.headline)
}
}
Spacer()
Image(systemName: "timer")
.font(.title)
.foregroundStyle(.secondary)
}
}
.foregroundStyle(viewModel.activeTimer != nil ? .green : .secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
private func statsSection(_ stats: TimeStatistics) -> some View {
// MARK: - Weekly Stats
private func weeklyStatsSection(_ stats: TimeStatistics) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("This Week")
.font(.headline)
@@ -85,11 +74,10 @@ struct DashboardView: View {
HStack(spacing: 12) {
StatCard(
title: "Hours Tracked",
value: TimeInterval(stats.totalSeconds).formattedHours,
value: TimeInterval(stats.totalSeconds).formattedShortDuration,
icon: "clock.fill",
color: .blue
)
StatCard(
title: "Entries",
value: "\(stats.entryCount)",
@@ -103,7 +91,6 @@ struct DashboardView: View {
Text("By Project")
.font(.subheadline)
.foregroundStyle(.secondary)
ForEach(stats.byProject.prefix(5)) { projectStat in
HStack {
if let color = projectStat.projectColor {
@@ -112,16 +99,34 @@ struct DashboardView: View {
Text(projectStat.projectName)
.font(.subheadline)
Spacer()
Text(TimeInterval(projectStat.totalSeconds).formattedHours)
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")
@@ -134,7 +139,8 @@ struct DashboardView: View {
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else {
ForEach(viewModel.recentEntries) { entry in
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) {
@@ -145,11 +151,15 @@ struct DashboardView: View {
.foregroundStyle(.secondary)
}
Spacer()
Text(entry.duration.formattedHours)
Text(entry.duration.formattedShortDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.padding(.vertical, 8)
if index < viewModel.recentEntries.count - 1 {
Divider()
}
}
}
}
}
@@ -163,3 +173,138 @@ struct DashboardView: View {
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)
}
}

View File

@@ -6,6 +6,7 @@ final class DashboardViewModel: ObservableObject {
@Published var activeTimer: OngoingTimer?
@Published var statistics: TimeStatistics?
@Published var recentEntries: [TimeEntry] = []
@Published var clientTargets: [ClientTarget] = []
@Published var isLoading = false
@Published var error: String?
@Published var elapsedTime: TimeInterval = 0
@@ -33,13 +34,14 @@ final class DashboardViewModel: ObservableObject {
authenticated: true
)
// Get statistics for this week
// Statistics for this week
let calendar = Calendar.current
let today = Date()
let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
let startOfWeek = calendar.date(
from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today)
)!
let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)!
let statsInput = StatisticsFiltersInput(startDate: startOfWeek, endDate: endOfWeek)
statistics = try await apiClient.request(
endpoint: APIEndpoint.timeEntriesStatistics,
queryItems: [
@@ -49,16 +51,20 @@ final class DashboardViewModel: ObservableObject {
authenticated: true
)
// Fetch recent entries
// Recent entries (last 5)
let entriesResponse: TimeEntryListResponse = try await apiClient.request(
endpoint: APIEndpoint.timeEntries,
queryItems: [
URLQueryItem(name: "limit", value: "5")
],
queryItems: [URLQueryItem(name: "limit", value: "5")],
authenticated: true
)
recentEntries = entriesResponse.entries
// Client targets (for overtime/undertime)
clientTargets = try await apiClient.request(
endpoint: APIEndpoint.clientTargets,
authenticated: true
)
if let timer = activeTimer {
elapsedTime = timer.elapsedTime
}
@@ -68,7 +74,7 @@ final class DashboardViewModel: ObservableObject {
isLoading = false
self.error = error.localizedDescription
// Try to load cached data
// Fallback to cached timer
if let cachedTimer = try? await database.getCachedTimer() {
activeTimer = cachedTimer
elapsedTime = cachedTimer.elapsedTime
@@ -80,9 +86,7 @@ final class DashboardViewModel: ObservableObject {
timerTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard let self = self, self.activeTimer != nil else { continue }
await MainActor.run {
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
}

View File

@@ -1,178 +1,3 @@
import SwiftUI
struct ProjectsView: View {
@StateObject private var viewModel = ProjectsViewModel()
@State private var showAddProject = false
@State private var projectToDelete: Project?
@State private var showDeleteConfirmation = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.projects.isEmpty {
LoadingView()
} else if let error = viewModel.error, viewModel.projects.isEmpty {
ErrorView(message: error) {
Task { await viewModel.loadData() }
}
} else if viewModel.projects.isEmpty {
EmptyView(
icon: "folder",
title: "No Projects",
message: "Create a project to start tracking time."
)
} else {
projectsList
}
}
.navigationTitle("Projects")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showAddProject = true
} label: {
Image(systemName: "plus")
}
}
}
.task {
await viewModel.loadData()
}
.sheet(isPresented: $showAddProject) {
ProjectFormView(
clients: viewModel.clients,
onSave: { name, description, color, clientId in
Task {
await viewModel.createProject(
name: name,
description: description,
color: color,
clientId: clientId
)
}
}
)
}
}
}
private var projectsList: some View {
List {
ForEach(viewModel.projects) { project in
ProjectRow(project: project)
}
.onDelete { indexSet in
if let index = indexSet.first {
projectToDelete = viewModel.projects[index]
showDeleteConfirmation = true
}
}
}
.alert("Delete Project?", isPresented: $showDeleteConfirmation, presenting: projectToDelete) { project in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteProject(project)
}
}
} message: { project in
Text("This will permanently delete '\(project.name)' and all related time entries. This action cannot be undone.")
}
.refreshable {
await viewModel.loadData()
}
}
}
struct ProjectRow: View {
let project: Project
var body: some View {
HStack {
ProjectColorDot(color: project.color, size: 16)
VStack(alignment: .leading, spacing: 2) {
Text(project.name)
.font(.headline)
Text(project.client.name)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
struct ProjectFormView: View {
@Environment(\.dismiss) private var dismiss
let clients: [Client]
let onSave: (String, String?, String?, String) -> Void
@State private var name = ""
@State private var description = ""
@State private var selectedColor: String = "#3B82F6"
@State private var selectedClient: Client?
private let colors = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Project name", text: $name)
}
Section("Description (Optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
Section("Color") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
ForEach(colors, id: \.self) { color in
Circle()
.fill(Color(hex: color))
.frame(width: 44, height: 44)
.overlay(
Circle()
.strokeBorder(Color.primary, lineWidth: selectedColor == color ? 3 : 0)
)
.onTapGesture {
selectedColor = color
}
}
}
.padding(.vertical, 8)
}
Section("Client") {
Picker("Client", selection: $selectedClient) {
Text("Select Client").tag(nil as Client?)
ForEach(clients) { client in
Text(client.name)
.tag(client as Client?)
}
}
}
}
.navigationTitle("New Project")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
guard let client = selectedClient else { return }
onSave(name, description.isEmpty ? nil : description, selectedColor, client.id)
dismiss()
}
.disabled(name.isEmpty || selectedClient == nil)
}
}
}
}
}
// ProjectsView.swift replaced by Features/Settings/ProjectsListView.swift
// This file is intentionally empty; ProjectsViewModel is no longer used directly.
import Foundation

View File

@@ -0,0 +1,608 @@
import SwiftUI
// MARK: - Clients List
struct ClientsListView: View {
@State private var clients: [Client] = []
@State private var isLoading = false
@State private var error: String?
@State private var showAddClient = false
@State private var clientToDelete: Client?
@State private var showDeleteConfirmation = false
private let apiClient = APIClient()
var body: some View {
Group {
if isLoading && clients.isEmpty {
LoadingView()
} else if let err = error, clients.isEmpty {
ErrorView(message: err) { Task { await loadClients() } }
} else if clients.isEmpty {
EmptyView(icon: "person.2", title: "No Clients",
message: "Create a client to organise your projects.")
} else {
List {
ForEach(clients) { client in
NavigationLink {
ClientDetailView(client: client, onUpdate: { Task { await loadClients() } })
} label: {
ClientRow(client: client)
}
}
.onDelete { indexSet in
if let i = indexSet.first {
clientToDelete = clients[i]
showDeleteConfirmation = true
}
}
}
.refreshable { await loadClients() }
}
}
.navigationTitle("Clients")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showAddClient = true } label: { Image(systemName: "plus") }
}
}
.task { await loadClients() }
.sheet(isPresented: $showAddClient) {
ClientFormSheet(mode: .create) { Task { await loadClients() } }
}
.alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) { Task { await deleteClient(client) } }
} message: { client in
Text("Deleting '\(client.name)' will also delete all its projects and time entries. This cannot be undone.")
}
}
private func loadClients() async {
isLoading = true; error = nil
do {
clients = try await apiClient.request(endpoint: APIEndpoint.clients, authenticated: true)
} catch { self.error = error.localizedDescription }
isLoading = false
}
private func deleteClient(_ client: Client) async {
do {
try await apiClient.requestVoid(endpoint: APIEndpoint.client(id: client.id),
method: .delete, authenticated: true)
clients.removeAll { $0.id == client.id }
} catch { self.error = error.localizedDescription }
}
}
struct ClientRow: View {
let client: Client
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(client.name).font(.headline)
if let desc = client.description {
Text(desc).font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Client Detail / Edit + Target Management
struct ClientDetailView: View {
let client: Client
let onUpdate: () -> Void
// Edit client fields
@State private var name: String
@State private var clientDescription: String
@State private var isSavingClient = false
@State private var clientSaveError: String?
@State private var clientSaveSuccess = false
// Client targets
@State private var target: ClientTarget?
@State private var isLoadingTarget = false
@State private var targetError: String?
// New target form
@State private var showNewTargetForm = false
@State private var newWeeklyHours = 40.0
@State private var newStartDate = Date().nextMonday()
@State private var isSavingTarget = false
// Edit target inline
@State private var editingWeeklyHours: Double?
@State private var editingStartDate: Date?
@State private var isEditingTarget = false
// Balance correction
@State private var showAddCorrection = false
@State private var correctionDate = Date()
@State private var correctionHours = 0.0
@State private var correctionDescription = ""
@State private var isSavingCorrection = false
@State private var correctionToDelete: BalanceCorrection?
@State private var showDeleteCorrection = false
private let apiClient = APIClient()
init(client: Client, onUpdate: @escaping () -> Void) {
self.client = client
self.onUpdate = onUpdate
_name = State(initialValue: client.name)
_clientDescription = State(initialValue: client.description ?? "")
}
var body: some View {
Form {
clientEditSection
targetSection
if let target { correctionsSection(target) }
}
.navigationTitle(client.name)
.navigationBarTitleDisplayMode(.inline)
.task { await loadTarget() }
.sheet(isPresented: $showAddCorrection) {
addCorrectionSheet
}
}
// MARK: - Client edit
private var clientEditSection: some View {
Section("Client Details") {
TextField("Name", text: $name)
TextField("Description (optional)", text: $clientDescription, axis: .vertical)
.lineLimit(2...4)
if let err = clientSaveError {
Text(err).font(.caption).foregroundStyle(.red)
}
if clientSaveSuccess {
Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green).font(.caption)
}
Button(isSavingClient ? "Saving…" : "Save Client Details") {
Task { await saveClient() }
}
.disabled(name.isEmpty || isSavingClient)
}
}
private func saveClient() async {
isSavingClient = true; clientSaveError = nil; clientSaveSuccess = false
do {
let input = UpdateClientInput(
name: name,
description: clientDescription.isEmpty ? nil : clientDescription
)
let _: Client = try await apiClient.request(
endpoint: APIEndpoint.client(id: client.id),
method: .put,
body: input,
authenticated: true
)
clientSaveSuccess = true
onUpdate()
} catch { clientSaveError = error.localizedDescription }
isSavingClient = false
}
// MARK: - Target section
private var targetSection: some View {
Section {
if isLoadingTarget {
HStack { Spacer(); ProgressView(); Spacer() }
} else if let err = targetError {
Text(err).font(.caption).foregroundStyle(.red)
} else if let target {
// Show existing target + balance
targetSummaryRows(target)
if isEditingTarget {
targetEditRows(target)
} else {
Button("Edit Target") { startEditingTarget(target) }
}
} else {
// No target yet
if showNewTargetForm {
newTargetFormRows
} else {
Button("Set Up Work Time Target") { showNewTargetForm = true }
}
}
} header: {
Text("Work Time Target")
}
}
private func targetSummaryRows(_ t: ClientTarget) -> some View {
Group {
HStack {
Text("Weekly hours")
Spacer()
Text("\(t.weeklyHours, specifier: "%.1f") h/week")
.foregroundStyle(.secondary)
}
HStack {
Text("Tracking since")
Spacer()
Text(formatDate(t.startDate))
.foregroundStyle(.secondary)
}
HStack {
Text("This week")
Spacer()
Text("\(TimeInterval(t.currentWeekTrackedSeconds).formattedShortDuration) / \(TimeInterval(t.currentWeekTargetSeconds).formattedShortDuration)")
.foregroundStyle(.secondary)
}
HStack {
Text("Total balance")
Spacer()
let balance = TimeInterval(abs(t.totalBalanceSeconds))
Text(t.totalBalanceSeconds >= 0 ? "+\(balance.formattedShortDuration)" : "-\(balance.formattedShortDuration)")
.fontWeight(.medium)
.foregroundStyle(t.totalBalanceSeconds >= 0 ? .green : .red)
}
}
}
private func targetEditRows(_ t: ClientTarget) -> some View {
Group {
HStack {
Text("Weekly hours")
Spacer()
TextField("Hours", value: Binding(
get: { editingWeeklyHours ?? t.weeklyHours },
set: { editingWeeklyHours = $0 }
), format: .number)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
}
DatePicker("Start date (Monday)",
selection: Binding(
get: { editingStartDate ?? parseDate(t.startDate) ?? Date() },
set: { editingStartDate = $0 }
),
displayedComponents: .date)
HStack {
Button("Cancel") { isEditingTarget = false; editingWeeklyHours = nil; editingStartDate = nil }
.foregroundStyle(.secondary)
Spacer()
Button(isSavingTarget ? "Saving…" : "Save Target") {
Task { await saveTarget(existingId: t.id) }
}
.disabled(isSavingTarget)
}
}
}
private var newTargetFormRows: some View {
Group {
HStack {
Text("Weekly hours")
Spacer()
TextField("Hours", value: $newWeeklyHours, format: .number)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 80)
}
DatePicker("Start date (Monday)", selection: $newStartDate, displayedComponents: .date)
HStack {
Button("Cancel") { showNewTargetForm = false }
.foregroundStyle(.secondary)
Spacer()
Button(isSavingTarget ? "Saving…" : "Create Target") {
Task { await createTarget() }
}
.disabled(newWeeklyHours <= 0 || isSavingTarget)
}
}
}
private func startEditingTarget(_ t: ClientTarget) {
editingWeeklyHours = t.weeklyHours
editingStartDate = parseDate(t.startDate)
isEditingTarget = true
}
// MARK: - Corrections section
private func correctionsSection(_ t: ClientTarget) -> some View {
Section {
if t.corrections.isEmpty {
Text("No corrections")
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
ForEach(t.corrections) { correction in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(formatDate(correction.date))
.font(.subheadline)
if let desc = correction.description {
Text(desc).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
Text(correction.hours >= 0 ? "+\(correction.hours, specifier: "%.1f")h" : "\(correction.hours, specifier: "%.1f")h")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(correction.hours >= 0 ? .green : .red)
}
}
.onDelete { indexSet in
if let i = indexSet.first {
correctionToDelete = t.corrections[i]
showDeleteCorrection = true
}
}
}
Button("Add Correction") { showAddCorrection = true }
} header: {
Text("Balance Corrections")
}
.alert("Delete Correction?", isPresented: $showDeleteCorrection, presenting: correctionToDelete) { correction in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await deleteCorrection(correction) }
}
} message: { correction in
Text("Remove the \(correction.hours >= 0 ? "+" : "")\(correction.hours, specifier: "%.1f")h correction on \(formatDate(correction.date))?")
}
}
// MARK: - Add correction sheet
private var addCorrectionSheet: some View {
NavigationStack {
Form {
Section("Date") {
DatePicker("Date", selection: $correctionDate, displayedComponents: .date)
}
Section("Hours adjustment") {
HStack {
TextField("Hours (positive = bonus, negative = penalty)",
value: $correctionHours, format: .number)
.keyboardType(.numbersAndPunctuation)
Text("h").foregroundStyle(.secondary)
}
Text("Positive values reduce the weekly target; negative values increase it.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Description (optional)") {
TextField("Note", text: $correctionDescription)
}
}
.navigationTitle("Add Correction")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showAddCorrection = false }
}
ToolbarItem(placement: .confirmationAction) {
Button(isSavingCorrection ? "Saving…" : "Add") {
Task { await addCorrection() }
}
.disabled(correctionHours == 0 || isSavingCorrection)
}
}
}
}
// MARK: - API calls
private func loadTarget() async {
isLoadingTarget = true; targetError = nil
do {
let allTargets: [ClientTarget] = try await apiClient.request(
endpoint: APIEndpoint.clientTargets, authenticated: true
)
target = allTargets.first { $0.clientId == client.id }
} catch { targetError = error.localizedDescription }
isLoadingTarget = false
}
private func createTarget() async {
isSavingTarget = true
do {
let input = CreateClientTargetInput(
clientId: client.id,
weeklyHours: newWeeklyHours,
startDate: newStartDate.iso8601FullDate
)
let created: ClientTarget = try await apiClient.request(
endpoint: APIEndpoint.clientTargets,
method: .post,
body: input,
authenticated: true
)
target = created
showNewTargetForm = false
} catch { targetError = error.localizedDescription }
isSavingTarget = false
}
private func saveTarget(existingId: String) async {
isSavingTarget = true
do {
let input = UpdateClientTargetInput(
weeklyHours: editingWeeklyHours,
startDate: editingStartDate?.iso8601FullDate
)
let _: ClientTarget = try await apiClient.request(
endpoint: APIEndpoint.clientTarget(id: existingId),
method: .put,
body: input,
authenticated: true
)
isEditingTarget = false
editingWeeklyHours = nil
editingStartDate = nil
await loadTarget() // reload to get fresh balance
} catch { targetError = error.localizedDescription }
isSavingTarget = false
}
private func addCorrection() async {
guard let t = target else { return }
isSavingCorrection = true
do {
let input = CreateBalanceCorrectionInput(
date: correctionDate.iso8601FullDate,
hours: correctionHours,
description: correctionDescription.isEmpty ? nil : correctionDescription
)
try await apiClient.requestVoid(
endpoint: APIEndpoint.clientTargetCorrections(targetId: t.id),
method: .post,
body: input,
authenticated: true
)
correctionHours = 0
correctionDescription = ""
showAddCorrection = false
await loadTarget()
} catch { targetError = error.localizedDescription }
isSavingCorrection = false
}
private func deleteCorrection(_ correction: BalanceCorrection) async {
guard let t = target else { return }
do {
try await apiClient.requestVoid(
endpoint: APIEndpoint.clientTargetCorrection(targetId: t.id, correctionId: correction.id),
method: .delete,
authenticated: true
)
await loadTarget()
} catch { targetError = error.localizedDescription }
}
// MARK: - Helpers
private func formatDate(_ string: String) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
guard let d = f.date(from: string) else { return string }
let out = DateFormatter()
out.dateStyle = .medium
return out.string(from: d)
}
private func parseDate(_ string: String) -> Date? {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f.date(from: string)
}
}
// MARK: - Client Form Sheet (create / edit)
struct ClientFormSheet: View {
enum Mode {
case create
case edit(Client)
}
@Environment(\.dismiss) private var dismiss
let mode: Mode
let onSave: () -> Void
@State private var name = ""
@State private var description = ""
@State private var isSaving = false
@State private var error: String?
private let apiClient = APIClient()
init(mode: Mode, onSave: @escaping () -> Void) {
self.mode = mode
self.onSave = onSave
if case .edit(let client) = mode {
_name = State(initialValue: client.name)
_description = State(initialValue: client.description ?? "")
}
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Client name", text: $name)
}
Section("Description (optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
if let error {
Section { Text(error).font(.caption).foregroundStyle(.red) }
}
}
.navigationTitle(isEditing ? "Edit Client" : "New Client")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "Saving…" : "Save") {
Task { await save() }
}
.disabled(name.isEmpty || isSaving)
}
}
}
}
private var isEditing: Bool {
if case .edit = mode { return true }
return false
}
private func save() async {
isSaving = true; error = nil
do {
switch mode {
case .create:
let input = CreateClientInput(name: name, description: description.isEmpty ? nil : description)
let _: Client = try await apiClient.request(
endpoint: APIEndpoint.clients, method: .post, body: input, authenticated: true
)
case .edit(let client):
let input = UpdateClientInput(name: name, description: description.isEmpty ? nil : description)
let _: Client = try await apiClient.request(
endpoint: APIEndpoint.client(id: client.id), method: .put, body: input, authenticated: true
)
}
isSaving = false
dismiss()
onSave()
} catch {
isSaving = false
self.error = error.localizedDescription
}
}
}
// MARK: - Date extension
private extension Date {
func nextMonday() -> Date {
let cal = Calendar.current
var comps = DateComponents()
comps.weekday = 2 // Monday
return cal.nextDate(after: self, matching: comps, matchingPolicy: .nextTime) ?? self
}
}

View File

@@ -0,0 +1,335 @@
import SwiftUI
// MARK: - Projects List (under Settings)
struct ProjectsListView: View {
@State private var projects: [Project] = []
@State private var clients: [Client] = []
@State private var isLoading = false
@State private var error: String?
@State private var showAddProject = false
@State private var projectToDelete: Project?
@State private var showDeleteConfirmation = false
private let apiClient = APIClient()
var body: some View {
Group {
if isLoading && projects.isEmpty {
LoadingView()
} else if let err = error, projects.isEmpty {
ErrorView(message: err) { Task { await loadData() } }
} else if projects.isEmpty {
EmptyView(icon: "folder", title: "No Projects",
message: "Create a project to start tracking time.")
} else {
projectList
}
}
.navigationTitle("Projects")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showAddProject = true } label: { Image(systemName: "plus") }
}
}
.task { await loadData() }
.sheet(isPresented: $showAddProject) {
ProjectFormSheet(mode: .create, clients: clients) {
Task { await loadData() }
}
}
.alert("Delete Project?", isPresented: $showDeleteConfirmation, presenting: projectToDelete) { project in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) { Task { await deleteProject(project) } }
} message: { project in
Text("Deleting '\(project.name)' will also delete all its time entries. This cannot be undone.")
}
}
// Group projects by client
private var projectsByClient: [(clientName: String, projects: [Project])] {
let grouped = Dictionary(grouping: projects) { $0.client.name }
return grouped.sorted { $0.key < $1.key }
.map { (clientName: $0.key, projects: $0.value.sorted { $0.name < $1.name }) }
}
private var projectList: some View {
List {
ForEach(projectsByClient, id: \.clientName) { group in
Section(group.clientName) {
ForEach(group.projects) { project in
NavigationLink {
ProjectDetailView(project: project, clients: clients) {
Task { await loadData() }
}
} label: {
ProjectListRow(project: project)
}
}
.onDelete { indexSet in
let deleteTargets = indexSet.map { group.projects[$0] }
if let first = deleteTargets.first {
projectToDelete = first
showDeleteConfirmation = true
}
}
}
}
}
.refreshable { await loadData() }
}
private func loadData() async {
isLoading = true; error = nil
do {
async let fetchProjects: [Project] = apiClient.request(endpoint: APIEndpoint.projects, authenticated: true)
async let fetchClients: [Client] = apiClient.request(endpoint: APIEndpoint.clients, authenticated: true)
projects = try await fetchProjects
clients = try await fetchClients
} catch { self.error = error.localizedDescription }
isLoading = false
}
private func deleteProject(_ project: Project) async {
do {
try await apiClient.requestVoid(endpoint: APIEndpoint.project(id: project.id),
method: .delete, authenticated: true)
projects.removeAll { $0.id == project.id }
} catch { self.error = error.localizedDescription }
}
}
// MARK: - Project list row
struct ProjectListRow: View {
let project: Project
var body: some View {
HStack(spacing: 12) {
ProjectColorDot(color: project.color, size: 14)
VStack(alignment: .leading, spacing: 2) {
Text(project.name).font(.headline)
if let desc = project.description {
Text(desc).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
.padding(.vertical, 2)
}
}
// MARK: - Project Detail / Edit
struct ProjectDetailView: View {
let project: Project
let clients: [Client]
let onUpdate: () -> Void
@State private var name: String
@State private var projectDescription: String
@State private var selectedColor: String
@State private var selectedClient: Client?
@State private var isSaving = false
@State private var saveError: String?
@State private var saveSuccess = false
private let colorPalette = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
private let apiClient = APIClient()
init(project: Project, clients: [Client], onUpdate: @escaping () -> Void) {
self.project = project
self.clients = clients
self.onUpdate = onUpdate
_name = State(initialValue: project.name)
_projectDescription = State(initialValue: project.description ?? "")
_selectedColor = State(initialValue: project.color ?? "#3B82F6")
_selectedClient = State(initialValue: clients.first { $0.id == project.clientId })
}
var body: some View {
Form {
Section("Name") {
TextField("Project name", text: $name)
}
Section("Description (optional)") {
TextField("Description", text: $projectDescription, axis: .vertical)
.lineLimit(2...5)
}
Section("Colour") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
ForEach(colorPalette, id: \.self) { color in
Circle()
.fill(Color(hex: color))
.frame(width: 44, height: 44)
.overlay(
Circle().strokeBorder(
Color.primary,
lineWidth: selectedColor == color ? 3 : 0
)
)
.onTapGesture { selectedColor = color }
}
}
.padding(.vertical, 8)
}
Section("Client") {
Picker("Client", selection: $selectedClient) {
Text("Select Client").tag(nil as Client?)
ForEach(clients) { client in
Text(client.name).tag(client as Client?)
}
}
.pickerStyle(.navigationLink)
}
if let err = saveError {
Section { Text(err).font(.caption).foregroundStyle(.red) }
}
if saveSuccess {
Section { Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green) }
}
Section {
Button(isSaving ? "Saving…" : "Save Project") {
Task { await save() }
}
.disabled(name.isEmpty || selectedClient == nil || isSaving)
}
}
.navigationTitle(project.name)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
// Ensure selectedClient resolves correctly once clients are available
if selectedClient == nil {
selectedClient = clients.first { $0.id == project.clientId }
}
}
}
private func save() async {
guard let client = selectedClient else { return }
isSaving = true; saveError = nil; saveSuccess = false
do {
let input = UpdateProjectInput(
name: name,
description: projectDescription.isEmpty ? nil : projectDescription,
color: selectedColor,
clientId: client.id
)
let _: Project = try await apiClient.request(
endpoint: APIEndpoint.project(id: project.id),
method: .put,
body: input,
authenticated: true
)
saveSuccess = true
onUpdate()
} catch { saveError = error.localizedDescription }
isSaving = false
}
}
// MARK: - Project Form Sheet (create)
struct ProjectFormSheet: View {
enum Mode { case create }
@Environment(\.dismiss) private var dismiss
let mode: Mode
let clients: [Client]
let onSave: () -> Void
@State private var name = ""
@State private var description = ""
@State private var selectedColor = "#3B82F6"
@State private var selectedClient: Client?
@State private var isSaving = false
@State private var error: String?
private let colorPalette = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
private let apiClient = APIClient()
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Project name", text: $name)
}
Section("Description (optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(2...5)
}
Section("Colour") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
ForEach(colorPalette, id: \.self) { color in
Circle()
.fill(Color(hex: color))
.frame(width: 44, height: 44)
.overlay(
Circle().strokeBorder(
Color.primary,
lineWidth: selectedColor == color ? 3 : 0
)
)
.onTapGesture { selectedColor = color }
}
}
.padding(.vertical, 8)
}
Section("Client") {
Picker("Client", selection: $selectedClient) {
Text("Select Client").tag(nil as Client?)
ForEach(clients) { client in
Text(client.name).tag(client as Client?)
}
}
.pickerStyle(.navigationLink)
}
if let error {
Section { Text(error).font(.caption).foregroundStyle(.red) }
}
}
.navigationTitle("New Project")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "Saving…" : "Save") {
Task { await save() }
}
.disabled(name.isEmpty || selectedClient == nil || isSaving)
}
}
}
}
private func save() async {
guard let client = selectedClient else { return }
isSaving = true; error = nil
do {
let input = CreateProjectInput(
name: name,
description: description.isEmpty ? nil : description,
color: selectedColor,
clientId: client.id
)
let _: Project = try await apiClient.request(
endpoint: APIEndpoint.projects, method: .post, body: input, authenticated: true
)
isSaving = false
dismiss()
onSave()
} catch {
isSaving = false
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,74 @@
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var authManager: AuthManager
@State private var showLogoutConfirmation = false
var body: some View {
NavigationStack {
List {
// User info header
if let user = authManager.currentUser {
Section {
HStack(spacing: 14) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 50, height: 50)
.overlay(
Text(user.username.prefix(1).uppercased())
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(Color.accentColor)
)
VStack(alignment: .leading, spacing: 2) {
Text(user.fullName ?? user.username)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 6)
}
}
// Navigation
Section("Data") {
NavigationLink {
ClientsListView()
} label: {
Label("Clients", systemImage: "person.2")
}
NavigationLink {
ProjectsListView()
} label: {
Label("Projects", systemImage: "folder")
}
}
// Logout
Section {
Button(role: .destructive) {
showLogoutConfirmation = true
} label: {
HStack {
Spacer()
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
Spacer()
}
}
}
}
.navigationTitle("Settings")
.alert("Sign Out?", isPresented: $showLogoutConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Sign Out", role: .destructive) {
Task { try? await authManager.logout() }
}
} message: {
Text("You will be signed out and need to sign in again to use the app.")
}
}
}
}

View File

@@ -2,10 +2,27 @@ import SwiftUI
struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel()
@State private var selectedDay: Date? = Calendar.current.startOfDay(for: Date())
@State private var visibleWeekStart: Date = Self.mondayOfWeek(containing: Date())
@State private var showFilterSheet = false
@State private var showAddEntry = false
@State private var entryToEdit: TimeEntry?
@State private var entryToDelete: TimeEntry?
@State private var showDeleteConfirmation = false
private static func mondayOfWeek(containing date: Date) -> Date {
var cal = Calendar.current
cal.firstWeekday = 2 // Monday
let comps = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)
return cal.date(from: comps) ?? Calendar.current.startOfDay(for: date)
}
private var visibleWeekDays: [Date] {
(0..<7).compactMap {
Calendar.current.date(byAdding: .day, value: $0, to: visibleWeekStart)
}
}
var body: some View {
NavigationStack {
Group {
@@ -15,109 +32,470 @@ struct TimeEntriesView: View {
ErrorView(message: error) {
Task { await viewModel.loadEntries() }
}
} else if viewModel.entries.isEmpty {
EmptyView(
icon: "clock",
title: "No Time Entries",
message: "Start tracking your time to see entries here."
)
} else {
entriesList
mainContent
}
}
.navigationTitle("Time Entries")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showAddEntry = true
} label: {
Image(systemName: "plus")
.navigationTitle("Entries")
.toolbar { toolbarContent }
.task { await viewModel.loadEntries() }
.refreshable { await viewModel.loadEntries() }
.sheet(isPresented: $showFilterSheet) {
TimeEntriesFilterSheet(viewModel: viewModel) {
Task { await viewModel.loadEntries() }
}
}
}
.task {
await viewModel.loadEntries()
}
.sheet(isPresented: $showAddEntry) {
TimeEntryFormView(onSave: {
TimeEntryDetailSheet(entry: nil) {
Task { await viewModel.loadEntries() }
})
}
}
.sheet(item: $entryToEdit) { entry in
TimeEntryDetailSheet(entry: entry) {
Task { await viewModel.loadEntries() }
}
}
.alert("Delete Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await viewModel.deleteEntry(entry) }
}
} message: { entry in
Text("Delete the time entry for '\(entry.project.name)'? This cannot be undone.")
}
}
}
private var entriesList: some View {
List {
ForEach(viewModel.entries) { entry in
TimeEntryRow(entry: entry)
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Button { showAddEntry = true } label: { Image(systemName: "plus") }
}
.onDelete { indexSet in
if let index = indexSet.first {
entryToDelete = viewModel.entries[index]
ToolbarItem(placement: .topBarLeading) {
Button {
Task { await viewModel.loadFilterSupportData() }
showFilterSheet = true
} label: {
Image(systemName: viewModel.activeFilters.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
}
}
}
// MARK: - Main content
private var mainContent: some View {
VStack(spacing: 0) {
WeekStripView(
weekDays: visibleWeekDays,
selectedDay: $selectedDay,
daysWithEntries: viewModel.daysWithEntries,
onSwipeLeft: {
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart
},
onSwipeRight: {
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart
}
)
Divider()
ScrollView {
if let day = selectedDay {
dayEntriesSection(for: day)
} else {
allEntriesSection
}
}
}
}
// MARK: - Day entries section
private func dayEntriesSection(for day: Date) -> some View {
let dayEntries = viewModel.entries(for: day)
return VStack(alignment: .leading, spacing: 0) {
// Section header
HStack {
Text(dayTitle(day))
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.primary)
Spacer()
Text(dayEntries.isEmpty ? "No entries" : "\(dayEntries.count) \(dayEntries.count == 1 ? "entry" : "entries")")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 12)
if dayEntries.isEmpty {
Text("No entries for this day")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
} else {
ForEach(Array(dayEntries.enumerated()), id: \.element.id) { index, entry in
EntryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture { entryToEdit = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
entryToDelete = entry
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
if index < dayEntries.count - 1 {
Divider().padding(.leading, 56)
}
}
}
.alert("Delete Time Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteEntry(entry)
}
}
} message: { entry in
Text("This will permanently delete the time entry for '\(entry.project.name)'. This action cannot be undone.")
// MARK: - All entries (no day selected) grouped by day
private var allEntriesSection: some View {
LazyVStack(alignment: .leading, pinnedViews: .sectionHeaders) {
ForEach(viewModel.entriesByDay, id: \.date) { group in
Section {
ForEach(Array(group.entries.enumerated()), id: \.element.id) { index, entry in
EntryRow(entry: entry)
.contentShape(Rectangle())
.onTapGesture { entryToEdit = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
entryToDelete = entry
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
.refreshable {
await viewModel.loadEntries()
}
if index < group.entries.count - 1 {
Divider().padding(.leading, 56)
}
}
} header: {
Text(dayTitle(group.date))
.font(.subheadline)
.fontWeight(.semibold)
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial)
}
}
}
}
// MARK: - Helpers
private func dayTitle(_ date: Date) -> String {
let cal = Calendar.current
if cal.isDateInToday(date) { return "Today" }
if cal.isDateInYesterday(date) { return "Yesterday" }
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
}
}
struct TimeEntryRow: View {
// MARK: - Entry Row
struct EntryRow: View {
let entry: TimeEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
ProjectColorDot(color: entry.project.color)
Text(entry.project.name)
.font(.headline)
Spacer()
Text(entry.duration.formattedHours)
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(alignment: .top, spacing: 12) {
// Color dot
ProjectColorDot(color: entry.project.color, size: 12)
.padding(.top, 4)
HStack {
Text(formatDateRange(start: entry.startTime, end: entry.endTime))
VStack(alignment: .leading, spacing: 3) {
Text(entry.project.name)
.font(.subheadline)
.fontWeight(.medium)
HStack(spacing: 6) {
Text(timeRange)
.font(.caption)
.foregroundStyle(.secondary)
Text("·")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(entry.project.client.name)
.font(.caption)
.foregroundStyle(.secondary)
}
if let description = entry.description {
Text(description)
if let desc = entry.description, !desc.isEmpty {
Text(desc)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
Spacer()
Text(entry.duration.formattedShortDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private func formatDateRange(start: String, end: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, HH:mm"
guard let startDate = Date.fromISO8601(start),
let endDate = Date.fromISO8601(end) else {
return ""
}
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
private var timeRange: String {
let fmt = DateFormatter()
fmt.dateFormat = "HH:mm"
let start = Date.fromISO8601(entry.startTime).map { fmt.string(from: $0) } ?? ""
let end = Date.fromISO8601(entry.endTime).map { fmt.string(from: $0) } ?? ""
return "\(start) \(end)"
}
}
// MARK: - Week Strip View
struct WeekStripView: View {
let weekDays: [Date]
@Binding var selectedDay: Date?
let daysWithEntries: Set<Date>
let onSwipeLeft: () -> Void
let onSwipeRight: () -> Void
@GestureState private var dragOffset: CGFloat = 0
private let cal = Calendar.current
private var monthYearLabel: String {
// Show the month/year of the majority of days in the strip
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let midWeek = weekDays.count >= 4 ? weekDays[3] : (weekDays.first ?? Date())
return formatter.string(from: midWeek)
}
var body: some View {
VStack(spacing: 4) {
// Month / year header with navigation arrows
HStack {
Button { onSwipeRight() } label: {
Image(systemName: "chevron.left")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 32, height: 32)
}
Spacer()
Text(monthYearLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Spacer()
Button { onSwipeLeft() } label: {
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 32, height: 32)
}
}
.padding(.horizontal, 8)
.padding(.top, 6)
// Day cells
HStack(spacing: 0) {
ForEach(weekDays, id: \.self) { day in
DayCell(
day: day,
isSelected: selectedDay.map { cal.isDate($0, inSameDayAs: day) } ?? false,
isToday: cal.isDateInToday(day),
hasDot: daysWithEntries.contains(cal.startOfDay(for: day))
)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture {
let normalized = cal.startOfDay(for: day)
if let current = selectedDay, cal.isDate(current, inSameDayAs: normalized) {
selectedDay = nil
} else {
selectedDay = normalized
}
}
}
}
.padding(.bottom, 6)
}
.gesture(
DragGesture(minimumDistance: 40, coordinateSpace: .local)
.onEnded { value in
if value.translation.width < -40 {
onSwipeLeft()
} else if value.translation.width > 40 {
onSwipeRight()
}
}
)
}
}
// MARK: - Day Cell
private struct DayCell: View {
let day: Date
let isSelected: Bool
let isToday: Bool
let hasDot: Bool
private static let weekdayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEEEE" // Single letter: M T W T F S S
return f
}()
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "d"
return f
}()
var body: some View {
VStack(spacing: 3) {
Text(Self.weekdayFormatter.string(from: day))
.font(.caption2)
.foregroundStyle(.secondary)
ZStack {
if isSelected {
Circle()
.fill(Color.accentColor)
.frame(width: 32, height: 32)
} else if isToday {
Circle()
.strokeBorder(Color.accentColor, lineWidth: 1.5)
.frame(width: 32, height: 32)
}
Text(Self.dayFormatter.string(from: day))
.font(.callout.weight(isToday || isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? .white : (isToday ? Color.accentColor : .primary))
}
.frame(width: 32, height: 32)
// Dot indicator
Circle()
.fill(hasDot ? Color.accentColor.opacity(isSelected ? 0 : 0.7) : Color.clear)
.frame(width: 4, height: 4)
}
.padding(.vertical, 4)
}
}
// MARK: - Filter Sheet
struct TimeEntriesFilterSheet: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: TimeEntriesViewModel
let onApply: () -> Void
@State private var startDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var endDate: Date = Date()
@State private var useStartDate = false
@State private var useEndDate = false
@State private var selectedProjectId: String?
@State private var selectedProjectName: String?
@State private var selectedClientId: String?
@State private var selectedClientName: String?
var body: some View {
NavigationStack {
Form {
Section("Date Range") {
Toggle("From", isOn: $useStartDate)
if useStartDate {
DatePicker("", selection: $startDate, displayedComponents: .date)
.labelsHidden()
}
Toggle("To", isOn: $useEndDate)
if useEndDate {
DatePicker("", selection: $endDate, displayedComponents: .date)
.labelsHidden()
}
}
Section("Project") {
Picker("Project", selection: $selectedProjectId) {
Text("Any Project").tag(nil as String?)
ForEach(viewModel.projects) { project in
HStack {
ProjectColorDot(color: project.color, size: 10)
Text(project.name)
}
.tag(project.id as String?)
}
}
.pickerStyle(.navigationLink)
.onChange(of: selectedProjectId) { _, newId in
selectedProjectName = viewModel.projects.first { $0.id == newId }?.name
}
}
Section("Client") {
Picker("Client", selection: $selectedClientId) {
Text("Any Client").tag(nil as String?)
ForEach(viewModel.clients) { client in
Text(client.name).tag(client.id as String?)
}
}
.pickerStyle(.navigationLink)
.onChange(of: selectedClientId) { _, newId in
selectedClientName = viewModel.clients.first { $0.id == newId }?.name
}
}
Section {
Button("Clear All Filters", role: .destructive) {
useStartDate = false
useEndDate = false
selectedProjectId = nil
selectedClientId = nil
}
}
}
.navigationTitle("Filter Entries")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") { applyAndDismiss() }
}
}
.onAppear { loadCurrentFilters() }
}
}
private func loadCurrentFilters() {
let f = viewModel.activeFilters
if let s = f.startDate { startDate = s; useStartDate = true }
if let e = f.endDate { endDate = e; useEndDate = true }
selectedProjectId = f.projectId
selectedClientId = f.clientId
}
private func applyAndDismiss() {
viewModel.activeFilters = TimeEntryActiveFilters(
startDate: useStartDate ? startDate : nil,
endDate: useEndDate ? endDate : nil,
projectId: selectedProjectId,
projectName: selectedProjectName,
clientId: selectedClientId,
clientName: selectedClientName
)
dismiss()
onApply()
}
}

View File

@@ -1,45 +1,54 @@
import Foundation
import SwiftUI
// MARK: - Active Filters (UI state)
struct TimeEntryActiveFilters: Equatable {
var startDate: Date?
var endDate: Date?
var projectId: String?
var projectName: String?
var clientId: String?
var clientName: String?
var isEmpty: Bool {
startDate == nil && endDate == nil && projectId == nil && clientId == nil
}
}
// MARK: - ViewModel
@MainActor
final class TimeEntriesViewModel: ObservableObject {
// All loaded entries (flat list, accumulated across pages)
@Published var entries: [TimeEntry] = []
@Published var pagination: Pagination?
@Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: String?
@Published var filters = TimeEntryFilters()
// Active filters driving the current fetch
@Published var activeFilters = TimeEntryActiveFilters()
// Projects and clients needed for filter sheet pickers
@Published var projects: [Project] = []
@Published var clients: [Client] = []
private let apiClient = APIClient()
func loadEntries() async {
// MARK: - Fetch
func loadEntries(resetPage: Bool = true) async {
if resetPage { entries = [] }
isLoading = true
error = nil
do {
var queryItems: [URLQueryItem] = []
if let startDate = filters.startDate {
queryItems.append(URLQueryItem(name: "startDate", value: startDate))
}
if let endDate = filters.endDate {
queryItems.append(URLQueryItem(name: "endDate", value: endDate))
}
if let projectId = filters.projectId {
queryItems.append(URLQueryItem(name: "projectId", value: projectId))
}
if let page = filters.page {
queryItems.append(URLQueryItem(name: "page", value: String(page)))
}
if let limit = filters.limit {
queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
}
let response: TimeEntryListResponse = try await apiClient.request(
endpoint: APIEndpoint.timeEntries,
queryItems: queryItems.isEmpty ? nil : queryItems,
queryItems: buildQueryItems(page: 1),
authenticated: true
)
entries = response.entries
pagination = response.pagination
isLoading = false
@@ -49,20 +58,27 @@ final class TimeEntriesViewModel: ObservableObject {
}
}
func nextPage() async {
guard let pagination = pagination,
func loadMoreIfNeeded(currentEntry entry: TimeEntry) async {
guard let pagination, !isLoadingMore,
pagination.page < pagination.totalPages else { return }
// Trigger when the last entry in the list becomes visible
guard entries.last?.id == entry.id else { return }
filters.page = pagination.page + 1
await loadEntries()
isLoadingMore = true
let nextPage = pagination.page + 1
do {
let response: TimeEntryListResponse = try await apiClient.request(
endpoint: APIEndpoint.timeEntries,
queryItems: buildQueryItems(page: nextPage),
authenticated: true
)
entries.append(contentsOf: response.entries)
self.pagination = response.pagination
} catch {
self.error = error.localizedDescription
}
func previousPage() async {
guard let pagination = pagination,
pagination.page > 1 else { return }
filters.page = pagination.page - 1
await loadEntries()
isLoadingMore = false
}
func deleteEntry(_ entry: TimeEntry) async {
@@ -72,10 +88,83 @@ final class TimeEntriesViewModel: ObservableObject {
method: .delete,
authenticated: true
)
entries.removeAll { $0.id == entry.id }
} catch {
self.error = error.localizedDescription
}
}
// MARK: - Supporting data for filter sheet
func loadFilterSupportData() async {
async let fetchProjects: [Project] = apiClient.request(
endpoint: APIEndpoint.projects,
authenticated: true
)
async let fetchClients: [Client] = apiClient.request(
endpoint: APIEndpoint.clients,
authenticated: true
)
projects = (try? await fetchProjects) ?? []
clients = (try? await fetchClients) ?? []
}
// MARK: - Entries grouped by calendar day
var entriesByDay: [(date: Date, entries: [TimeEntry])] {
let calendar = Calendar.current
let grouped = Dictionary(grouping: entries) { entry -> Date in
guard let d = Date.fromISO8601(entry.startTime) else { return Date() }
return calendar.startOfDay(for: d)
}
return grouped
.sorted { $0.key > $1.key }
.map { (date: $0.key, entries: $0.value.sorted {
(Date.fromISO8601($0.startTime) ?? Date()) > (Date.fromISO8601($1.startTime) ?? Date())
}) }
}
/// All calendar days that have at least one entry (for dot decorations)
var daysWithEntries: Set<Date> {
let calendar = Calendar.current
return Set(entries.compactMap { entry in
guard let d = Date.fromISO8601(entry.startTime) else { return nil }
return calendar.startOfDay(for: d)
})
}
/// Entries for a specific calendar day
func entries(for day: Date) -> [TimeEntry] {
let calendar = Calendar.current
return entries.filter { entry in
guard let d = Date.fromISO8601(entry.startTime) else { return false }
return calendar.isDate(d, inSameDayAs: day)
}.sorted {
(Date.fromISO8601($0.startTime) ?? Date()) < (Date.fromISO8601($1.startTime) ?? Date())
}
}
// MARK: - Helpers
private func buildQueryItems(page: Int) -> [URLQueryItem] {
var items: [URLQueryItem] = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "100")
]
if let start = activeFilters.startDate {
items.append(URLQueryItem(name: "startDate", value: start.iso8601String))
}
if let end = activeFilters.endDate {
// Push to end-of-day so the full day is included
let endOfDay = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: end) ?? end
items.append(URLQueryItem(name: "endDate", value: endOfDay.iso8601String))
}
if let pid = activeFilters.projectId {
items.append(URLQueryItem(name: "projectId", value: pid))
}
if let cid = activeFilters.clientId {
items.append(URLQueryItem(name: "clientId", value: cid))
}
return items
}
}

View File

@@ -0,0 +1,212 @@
import SwiftUI
/// Detail/edit sheet for a single time entry. Used both for creating new entries
/// (pass `entry: nil`) and editing existing ones.
struct TimeEntryDetailSheet: View {
@Environment(\.dismiss) private var dismiss
// Pass an existing entry to edit it; pass nil to create a new one.
let entry: TimeEntry?
let onSave: () -> Void
// Form state
@State private var startDateTime = Date()
@State private var endDateTime = Date()
@State private var description = ""
@State private var selectedProject: Project?
// Supporting data
@State private var projects: [Project] = []
@State private var isLoading = false
@State private var isSaving = false
@State private var error: String?
private let apiClient = APIClient()
init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) {
self.entry = entry
self.onSave = onSave
}
// MARK: - Body
var body: some View {
NavigationStack {
Form {
projectSection
timeSection
descriptionSection
if let error { errorSection(error) }
}
.navigationTitle(entry == nil ? "New Entry" : "Edit Entry")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView().controlSize(.small)
} else {
Button("Save") { Task { await save() } }
.disabled(selectedProject == nil || endDateTime <= startDateTime)
}
}
}
.task {
await loadProjects()
populateFromEntry()
}
.overlay { if isLoading { LoadingView() } }
}
}
// MARK: - Sections
private var projectSection: some View {
Section("Project") {
Picker("Project", selection: $selectedProject) {
Text("Select Project").tag(nil as Project?)
ForEach(projects) { project in
HStack {
ProjectColorDot(color: project.color, size: 10)
Text(project.name)
Text("· \(project.client.name)")
.foregroundStyle(.secondary)
}
.tag(project as Project?)
}
}
.pickerStyle(.navigationLink)
}
}
private var timeSection: some View {
Section {
DatePicker("Start", selection: $startDateTime)
DatePicker("End", selection: $endDateTime, in: startDateTime...)
if endDateTime > startDateTime {
HStack {
Text("Duration")
.foregroundStyle(.secondary)
Spacer()
Text(endDateTime.timeIntervalSince(startDateTime).formattedShortDuration)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Time")
}
}
private var descriptionSection: some View {
Section("Description") {
TextField("Optional notes…", text: $description, axis: .vertical)
.lineLimit(3...8)
}
}
private func errorSection(_ message: String) -> some View {
Section {
Text(message)
.font(.caption)
.foregroundStyle(.red)
}
}
// MARK: - Data loading
private func loadProjects() async {
isLoading = true
do {
projects = try await apiClient.request(
endpoint: APIEndpoint.projects,
authenticated: true
)
// Re-apply project selection now that the list is populated
matchProjectAfterLoad()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
private func populateFromEntry() {
guard let entry else {
// Default: now rounded to minute, 1-hour window
let now = Date().roundedToMinute()
startDateTime = now
endDateTime = now.addingTimeInterval(3600)
return
}
startDateTime = Date.fromISO8601(entry.startTime) ?? Date()
endDateTime = Date.fromISO8601(entry.endTime) ?? Date()
description = entry.description ?? ""
// Pre-select the project once projects are loaded
if !projects.isEmpty {
selectedProject = projects.first { $0.id == entry.projectId }
}
}
// Called after projects load re-apply the project selection if it wasn't
// set yet (projects may have loaded after populateFromEntry ran).
private func matchProjectAfterLoad() {
guard let entry, selectedProject == nil else { return }
selectedProject = projects.first { $0.id == entry.projectId }
}
// MARK: - Save
private func save() async {
guard let project = selectedProject else { return }
isSaving = true
error = nil
do {
if let existingEntry = entry {
let input = UpdateTimeEntryInput(
startTime: startDateTime.iso8601String,
endTime: endDateTime.iso8601String,
description: description.isEmpty ? nil : description,
projectId: project.id
)
try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
method: .put,
body: input,
authenticated: true
)
} else {
let input = CreateTimeEntryInput(
startTime: startDateTime,
endTime: endDateTime,
description: description.isEmpty ? nil : description,
projectId: project.id
)
try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntries,
method: .post,
body: input,
authenticated: true
)
}
isSaving = false
dismiss()
onSave()
} catch {
isSaving = false
self.error = error.localizedDescription
}
}
}
// MARK: - Date rounding helper
private extension Date {
func roundedToMinute() -> Date {
let cal = Calendar.current
var comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: self)
comps.second = 0
return cal.date(from: comps) ?? self
}
}

View File

@@ -1,171 +1,2 @@
import SwiftUI
struct TimeEntryFormView: View {
@Environment(\.dismiss) private var dismiss
let entry: TimeEntry?
let onSave: () -> Void
@State private var startDate = Date()
@State private var startTime = Date()
@State private var endDate = Date()
@State private var endTime = Date()
@State private var description = ""
@State private var selectedProject: Project?
@State private var projects: [Project] = []
@State private var isLoading = false
@State private var error: String?
private let apiClient = APIClient()
init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) {
self.entry = entry
self.onSave = onSave
}
var body: some View {
NavigationStack {
Form {
Section("Project") {
Picker("Project", selection: $selectedProject) {
Text("Select Project").tag(nil as Project?)
ForEach(projects) { project in
HStack {
ProjectColorDot(color: project.color)
Text(project.name)
}
.tag(project as Project?)
}
}
}
Section("Start Time") {
DatePicker("Date", selection: $startDate, displayedComponents: .date)
DatePicker("Time", selection: $startTime, displayedComponents: .hourAndMinute)
}
Section("End Time") {
DatePicker("Date", selection: $endDate, displayedComponents: .date)
DatePicker("Time", selection: $endTime, displayedComponents: .hourAndMinute)
}
Section("Description (Optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle(entry == nil ? "New Entry" : "Edit Entry")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
save()
}
.disabled(selectedProject == nil || isLoading)
}
}
.task {
await loadProjects()
if let entry = entry {
await loadEntry(entry)
}
}
}
}
private func loadProjects() async {
do {
projects = try await apiClient.request(
endpoint: APIEndpoint.projects,
authenticated: true
)
} catch {
self.error = error.localizedDescription
}
}
private func loadEntry(_ entry: TimeEntry) async {
let startFormatter = DateFormatter()
startFormatter.dateFormat = "yyyy-MM-dd"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"
if let startDateObj = Date.fromISO8601(entry.startTime) {
startDate = startDateObj
startTime = startDateObj
}
if let endDateObj = Date.fromISO8601(entry.endTime) {
endDate = endDateObj
endTime = endDateObj
}
description = entry.description ?? ""
selectedProject = projects.first { $0.id == entry.projectId }
}
private func save() {
guard let project = selectedProject else { return }
isLoading = true
let calendar = Calendar.current
let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime),
minute: calendar.component(.minute, from: startTime),
second: 0,
of: startDate) ?? startDate
let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime),
minute: calendar.component(.minute, from: endTime),
second: 0,
of: endDate) ?? endDate
Task {
do {
if let existingEntry = entry {
let input = UpdateTimeEntryInput(
startTime: startDateTime.iso8601String,
endTime: endDateTime.iso8601String,
description: description.isEmpty ? nil : description,
projectId: project.id
)
try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
method: .put,
body: input,
authenticated: true
)
} else {
let input = CreateTimeEntryInput(
startTime: startDateTime,
endTime: endDateTime,
description: description.isEmpty ? nil : description,
projectId: project.id
)
try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntries,
method: .post,
body: input,
authenticated: true
)
}
await MainActor.run {
isLoading = false
dismiss()
onSave()
}
} catch {
let errorMessage = error.localizedDescription
await MainActor.run {
isLoading = false
self.error = errorMessage
}
}
}
}
}
// TimeEntryFormView.swift replaced by TimeEntryDetailSheet.swift
import Foundation

View File

@@ -0,0 +1,62 @@
import Foundation
// MARK: - Client Target
struct ClientTarget: Codable, Identifiable, Equatable {
let id: String
let clientId: String
let clientName: String
let userId: String
let weeklyHours: Double
let startDate: String // "YYYY-MM-DD"
let createdAt: String
let updatedAt: String
let corrections: [BalanceCorrection]
// Computed balance fields returned by the API
let totalBalanceSeconds: Int
let currentWeekTrackedSeconds: Int
let currentWeekTargetSeconds: Int
let weeks: [WeekBalance]
}
// MARK: - Week Balance
struct WeekBalance: Codable, Identifiable, Equatable {
var id: String { weekStart }
let weekStart: String // "YYYY-MM-DD"
let weekEnd: String
let trackedSeconds: Int
let targetSeconds: Int
let correctionHours: Double
let balanceSeconds: Int
}
// MARK: - Balance Correction
struct BalanceCorrection: Codable, Identifiable, Equatable {
let id: String
let date: String // "YYYY-MM-DD"
let hours: Double
let description: String?
let createdAt: String
}
// MARK: - Input Types
struct CreateClientTargetInput: Codable {
let clientId: String
let weeklyHours: Double
let startDate: String // "YYYY-MM-DD", must be a Monday
}
struct UpdateClientTargetInput: Codable {
let weeklyHours: Double?
let startDate: String?
}
struct CreateBalanceCorrectionInput: Codable {
let date: String // "YYYY-MM-DD"
let hours: Double
let description: String?
}

View File

@@ -29,10 +29,12 @@ struct StatCard: View {
}
extension TimeInterval {
/// Formats as a clock string used for the live timer display: "1:23:45" or "05:30".
var formattedDuration: String {
let hours = Int(self) / 3600
let minutes = (Int(self) % 3600) / 60
let seconds = Int(self) % 60
let totalSeconds = Int(self)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
@@ -41,6 +43,24 @@ extension TimeInterval {
}
}
/// Human-readable duration used in lists and cards: "3h 48min", "45min", "< 1min".
var formattedShortDuration: String {
let totalSeconds = Int(self)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
if hours > 0 && minutes > 0 {
return "\(hours)h \(minutes)min"
} else if hours > 0 {
return "\(hours)h"
} else if minutes > 0 {
return "\(minutes)min"
} else {
return "< 1min"
}
}
/// Formats as hours with one decimal place, e.g. "3.8h". Used by the widget.
var formattedHours: String {
let hours = self / 3600
return String(format: "%.1fh", hours)

View File

@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.simonfranken.timetracker</string>
<string>group.com.simonfranken.timetracker.app</string>
</array>
</dict>
</plist>

View File

@@ -35,34 +35,20 @@ struct MainTabView: View {
var body: some View {
TabView(selection: $selectedTab) {
DashboardView()
.tabItem {
Label("Dashboard", systemImage: "chart.bar")
}
.tabItem { Label("Dashboard", systemImage: "chart.bar") }
.tag(0)
TimerView()
.tabItem {
Label("Timer", systemImage: "timer")
}
.tabItem { Label("Timer", systemImage: "timer") }
.tag(1)
TimeEntriesView()
.tabItem {
Label("Entries", systemImage: "clock")
}
.tabItem { Label("Entries", systemImage: "calendar") }
.tag(2)
ProjectsView()
.tabItem {
Label("Projects", systemImage: "folder")
}
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
.tag(3)
ClientsView()
.tabItem {
Label("Clients", systemImage: "person.2")
}
.tag(4)
}
}
}

View File

@@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.simonfranken.timetracker</string>
<string>group.com.simonfranken.timetracker.app</string>
</array>
</dict>
</plist>