14 Commits

Author SHA1 Message Date
7dd3873148 Merge branch 'main' into feature/soft-delete 2026-02-23 17:59:29 +01:00
850f12e09d Merge pull request 'feature/ios-time-entries-rework' (#2) from feature/ios-time-entries-rework into main
Reviewed-on: #2
2026-02-23 16:58:44 +00:00
74999ce265 Merge pull request 'ios-rebuild' (#3) from ios-rebuild into main
Reviewed-on: #3
2026-02-23 16:57:45 +00:00
0c0fbf42ef updates icons 2026-02-23 17:57:25 +01:00
0d116c8c26 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 17:29:21 +01:00
25b7371d08 Merge branch 'main' into ios-rebuild 2026-02-23 17:28:51 +01:00
simon.franken
c99bdf56e6 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 10:12:12 +01:00
simon.franken
15abfe0511 Merge branch 'main' into ios-rebuild 2026-02-23 10:12:01 +01:00
simon.franken
544b86c948 fix(ios): replace 2001-page TabView with 3-page recycling carousel
Eliminates the eager instantiation of 2001 view bodies by keeping only
three pages (previous, current, next) alive at all times. After each
swipe settles, dayOffset is shifted and tabSelection is silently reset
to the middle page, preserving the native paging animation.
2026-02-23 10:09:51 +01:00
simon.franken
b971569983 feat(ios): replace week strip with native DatePicker and TabView paging 2026-02-23 10:02:24 +01:00
b613fe4edd Replace full-month UICalendarView with compact week strip in time entries 2026-02-21 18:01:02 +01:00
30d5139ad8 update 2026-02-21 14:03:56 +01:00
ef38578596 Fix .accentColor ShapeStyle compile error — use Color.accentColor 2026-02-21 13:57:31 +01:00
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
21 changed files with 2122 additions and 820 deletions

View File

@@ -1,40 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" width="100%" height="100%">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818CF8" />
<stop offset="100%" stop-color="#4F46E5" />
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 4.0 -->
<svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
<linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient>
</defs>
<!-- App Icon Background -->
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" />
<!-- Inner Icon Group -->
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- Stopwatch Top Button -->
<path d="M256 96 v44" stroke-width="28" />
<path d="M224 88 h64" stroke-width="24" />
<!-- Stopwatch Side Button -->
<path d="M352 176 l 24 -24" stroke-width="24" />
<!-- Cap for side button -->
<path d="M362 138 l 28 28" stroke-width="24" />
<!-- Outer Ring -->
<circle cx="256" cy="272" r="132" stroke-width="28" />
<!-- Clock Hands -->
<!-- Minute Hand -->
<path d="M256 184 v 88" stroke-width="24" />
<!-- Hour Hand -->
<path d="M256 272 l 48 32" stroke-width="24" />
<!-- Dial Tick Marks -->
<g stroke-width="12" opacity="0.6">
<line x1="256" y1="172" x2="256" y2="188" />
<line x1="256" y1="356" x2="256" y2="372" />
<line x1="172" y1="272" x2="188" y2="272" />
<line x1="340" y1="272" x2="324" y2="272" />
<path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 96 0 L 320 0 C 373.019348 0 416 42.980652 416 96 L 416 320 C 416 373.019348 373.019348 416 320 416 L 96 416 C 42.980667 416 0 373.019348 0 320 L 0 96 C 0 42.980652 42.980667 0 96 0 Z"/>
<g id="Group">
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 176 40 L 240 40"/>
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 304 128 L 328 104"/>
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 340 224 C 340 296.901581 280.901581 356 208 356 C 135.098419 356 76 296.901581 76 224 C 76 151.098419 135.098419 92 208 92 C 280.901581 92 340 151.098419 340 224 Z"/>
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
<g id="g1" opacity="0.6">
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 208 308 L 208 324"/>
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,43 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818CF8" />
<stop offset="100%" stop-color="#4F46E5" />
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 4.0 -->
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" />
<filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="16"/>
<feOffset dx="0" dy="12" result="offsetblur"/>
<feFlood flood-color="#4f46e5" flood-opacity="0.4"/>
<feComposite in2="offsetblur" operator="in"/>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- App Icon Background -->
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" />
<!-- Inner Icon Group -->
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- Stopwatch Top Button -->
<path d="M256 96 v44" stroke-width="28" />
<path d="M224 88 h64" stroke-width="24" />
<!-- Stopwatch Side Button -->
<path d="M352 176 l 24 -24" stroke-width="24" />
<!-- Cap for side button -->
<path d="M362 138 l 28 28" stroke-width="24" />
<!-- Outer Ring -->
<circle cx="256" cy="272" r="132" stroke-width="28" />
<!-- Clock Hands -->
<!-- Minute Hand -->
<path d="M256 184 v 88" stroke-width="24" />
<!-- Hour Hand -->
<path d="M256 272 l 48 32" stroke-width="24" />
<!-- Dial Tick Marks -->
<g stroke-width="12" opacity="0.6">
<line x1="256" y1="172" x2="256" y2="188" />
<line x1="256" y1="356" x2="256" y2="372" />
<line x1="172" y1="272" x2="188" y2="272" />
<line x1="340" y1="272" x2="324" y2="272" />
<path id="Path" fill="url(#linearGradient1)" stroke="none" filter="url(#filter1)" d="M 144 48 L 368 48 C 421.019348 48 464 90.980652 464 144 L 464 368 C 464 421.019348 421.019348 464 368 464 L 144 464 C 90.980667 464 48 421.019348 48 368 L 48 144 C 48 90.980652 90.980667 48 144 48 Z"/>
<g id="Group">
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 224 88 L 288 88"/>
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 352 176 L 376 152"/>
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 388 272 C 388 344.901581 328.901581 404 256 404 C 183.098419 404 124 344.901581 124 272 C 124 199.098419 183.098419 140 256 140 C 328.901581 140 388 199.098419 388 272 Z"/>
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
<g id="g1" opacity="0.6">
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 256 356 L 256 372"/>
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

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,7 +2,16 @@ import SwiftUI
struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel()
// dayOffset is the source of truth: 0 = today, -1 = yesterday, etc.
@State private var dayOffset: Int = 0
// tabSelection is always snapped back to 1 (middle) after each swipe.
// Pages are: 0 = dayOffset-1, 1 = dayOffset, 2 = dayOffset+1
@State private var tabSelection: Int = 1
@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
@@ -15,109 +24,338 @@ 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")
.navigationBarTitleDisplayMode(.inline)
.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() }
})
}
}
}
private var entriesList: some View {
List {
ForEach(viewModel.entries) { entry in
TimeEntryRow(entry: entry)
}
.onDelete { indexSet in
if let index = indexSet.first {
entryToDelete = viewModel.entries[index]
showDeleteConfirmation = true
.sheet(item: $entryToEdit) { entry in
TimeEntryDetailSheet(entry: entry) {
Task { await viewModel.loadEntries() }
}
}
}
.alert("Delete Time Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
.alert("Delete Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteEntry(entry)
}
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.")
}
.refreshable {
await viewModel.loadEntries()
Text("Delete the time entry for '\(entry.project.name)'? This cannot be undone.")
}
}
}
struct TimeEntryRow: View {
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Button { showAddEntry = true } label: { Image(systemName: "plus") }
}
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")
}
}
// Place the DatePicker in the principal placement (center of nav bar)
ToolbarItem(placement: .principal) {
DatePicker(
"",
selection: Binding(
get: {
Calendar.current.date(byAdding: .day, value: dayOffset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
},
set: { newDate in
let today = Calendar.current.startOfDay(for: Date())
let normalizedNewDate = Calendar.current.startOfDay(for: newDate)
let components = Calendar.current.dateComponents([.day], from: today, to: normalizedNewDate)
if let dayDifference = components.day {
dayOffset = dayDifference
}
}
),
displayedComponents: .date
)
.datePickerStyle(.compact)
.labelsHidden()
.environment(\.locale, Locale.current) // Ensure correct start of week
}
}
// MARK: - Main content
private var mainContent: some View {
// Only 3 pages exist at any time: previous, current, next.
// After each swipe settles, we reset tabSelection to 1 and shift
// dayOffset, so the carousel appears infinite while staying cheap.
TabView(selection: $tabSelection) {
ForEach(0..<3, id: \.self) { page in
let offset = dayOffset + (page - 1)
let day = Calendar.current.date(
byAdding: .day,
value: offset,
to: Calendar.current.startOfDay(for: Date())
) ?? Date()
ScrollView {
dayEntriesSection(for: day)
}
.tag(page)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: tabSelection) { _, newPage in
guard newPage != 1 else { return }
// Shift the logical day offset by how many pages we moved.
dayOffset += newPage - 1
// Snap back to the middle page without animation so the
// surrounding pages are refreshed invisibly.
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
tabSelection = 1
}
}
}
// MARK: - Day entries section
private func dayEntriesSection(for day: Date) -> some View {
let dayEntries = viewModel.entries(for: day)
return VStack(alignment: .leading, spacing: 0) {
// Optional: A small summary header for the day
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)
}
}
}
}
.padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
}
// MARK: - Helpers
private func dayTitle(_ date: Date) -> String {
let cal = Calendar.current
if cal.isDateInToday(date) { return "Today" }
if cal.isDateInYesterday(date) { return "Yesterday" }
if cal.isDateInTomorrow(date) { return "Tomorrow" }
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
}
}
// 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 ""
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)"
}
}
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
// 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

@@ -1,6 +1,29 @@
{
"images" : [
{
"filename" : "app_icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

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>