diff --git a/backend/pom.xml b/backend/pom.xml
index 5704873..47431a8 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -46,6 +46,11 @@
postgresql
runtime
+
+ org.projectlombok
+ lombok
+ true
+
org.springframework.boot
spring-boot-starter-test
diff --git a/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java b/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java
new file mode 100644
index 0000000..50a9511
--- /dev/null
+++ b/backend/src/main/java/com/example/app/common/GlobalExceptionHandler.java
@@ -0,0 +1,44 @@
+package com.example.app.common;
+
+import jakarta.persistence.EntityNotFoundException;
+import java.net.URI;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
+ ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
+ pd.setType(URI.create("urn:problem:validation-error"));
+ pd.setTitle("Validation failed");
+ pd.setProperty(
+ "fields",
+ ex.getBindingResult().getFieldErrors().stream()
+ .map(e -> e.getField() + ": " + e.getDefaultMessage())
+ .toList());
+ return pd;
+ }
+
+ @ExceptionHandler(EntityNotFoundException.class)
+ public ProblemDetail handleNotFound(EntityNotFoundException ex) {
+ ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
+ pd.setType(URI.create("urn:problem:not-found"));
+ pd.setTitle("Resource not found");
+ pd.setDetail(ex.getMessage());
+ return pd;
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ProblemDetail handleIllegalArg(IllegalArgumentException ex) {
+ ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
+ pd.setType(URI.create("urn:problem:bad-request"));
+ pd.setTitle("Bad request");
+ pd.setDetail(ex.getMessage());
+ return pd;
+ }
+}
diff --git a/backend/src/main/java/com/example/app/project/Project.java b/backend/src/main/java/com/example/app/project/Project.java
new file mode 100644
index 0000000..6aafa8e
--- /dev/null
+++ b/backend/src/main/java/com/example/app/project/Project.java
@@ -0,0 +1,30 @@
+package com.example.app.project;
+
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(name = "projects")
+@Getter
+@Setter
+@NoArgsConstructor
+public class Project {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private int timeBudgetMinutes;
+
+ @Column(nullable = false)
+ private int costBudgetCents;
+
+ /** Null means fall back to the logging user's defaultHourlyRateCents. */
+ @Column private Integer hourlyRateCents;
+}
diff --git a/backend/src/main/java/com/example/app/project/ProjectController.java b/backend/src/main/java/com/example/app/project/ProjectController.java
new file mode 100644
index 0000000..f3fa058
--- /dev/null
+++ b/backend/src/main/java/com/example/app/project/ProjectController.java
@@ -0,0 +1,30 @@
+package com.example.app.project;
+
+import com.example.app.project.ProjectDtos.CreateProjectRequest;
+import com.example.app.project.ProjectDtos.ProjectResponse;
+import jakarta.validation.Valid;
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/projects")
+public class ProjectController {
+
+ private final ProjectService projectService;
+
+ public ProjectController(ProjectService projectService) {
+ this.projectService = projectService;
+ }
+
+ @GetMapping
+ public List list() {
+ return projectService.findAll();
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public ProjectResponse create(@Valid @RequestBody CreateProjectRequest req) {
+ return projectService.create(req);
+ }
+}
diff --git a/backend/src/main/java/com/example/app/project/ProjectDtos.java b/backend/src/main/java/com/example/app/project/ProjectDtos.java
new file mode 100644
index 0000000..b6cfb4b
--- /dev/null
+++ b/backend/src/main/java/com/example/app/project/ProjectDtos.java
@@ -0,0 +1,39 @@
+package com.example.app.project;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.PositiveOrZero;
+
+public class ProjectDtos {
+
+ public record CreateProjectRequest(
+ @NotBlank String name,
+ @Min(1) int timeBudgetMinutes,
+ @PositiveOrZero int costBudgetCents,
+ @PositiveOrZero Integer hourlyRateCents) {}
+
+ public record ProjectResponse(
+ Long id,
+ String name,
+ int timeBudgetMinutes,
+ int costBudgetCents,
+ Integer hourlyRateCents,
+ long minutesLogged,
+ long costLoggedCents,
+ boolean timeExceeded,
+ boolean costExceeded) {
+
+ public static ProjectResponse from(Project p, long minutesLogged, long costLoggedCents) {
+ return new ProjectResponse(
+ p.getId(),
+ p.getName(),
+ p.getTimeBudgetMinutes(),
+ p.getCostBudgetCents(),
+ p.getHourlyRateCents(),
+ minutesLogged,
+ costLoggedCents,
+ minutesLogged > p.getTimeBudgetMinutes(),
+ costLoggedCents > p.getCostBudgetCents());
+ }
+ }
+}
diff --git a/backend/src/main/java/com/example/app/project/ProjectRepository.java b/backend/src/main/java/com/example/app/project/ProjectRepository.java
new file mode 100644
index 0000000..2515160
--- /dev/null
+++ b/backend/src/main/java/com/example/app/project/ProjectRepository.java
@@ -0,0 +1,5 @@
+package com.example.app.project;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ProjectRepository extends JpaRepository {}
diff --git a/backend/src/main/java/com/example/app/project/ProjectService.java b/backend/src/main/java/com/example/app/project/ProjectService.java
new file mode 100644
index 0000000..2dcbd4a
--- /dev/null
+++ b/backend/src/main/java/com/example/app/project/ProjectService.java
@@ -0,0 +1,56 @@
+package com.example.app.project;
+
+import com.example.app.project.ProjectDtos.CreateProjectRequest;
+import com.example.app.project.ProjectDtos.ProjectResponse;
+import com.example.app.time.TimeEntryRepository;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class ProjectService {
+
+ private final ProjectRepository projectRepository;
+ private final TimeEntryRepository timeEntryRepository;
+
+ public ProjectService(
+ ProjectRepository projectRepository, TimeEntryRepository timeEntryRepository) {
+ this.projectRepository = projectRepository;
+ this.timeEntryRepository = timeEntryRepository;
+ }
+
+ @Transactional(readOnly = true)
+ public List findAll() {
+ List projects = projectRepository.findAll();
+
+ // one aggregate query for totals across all entries
+ Map totals = new HashMap<>();
+ for (Object[] row : timeEntryRepository.aggregateAllByProject()) {
+ long projectId = ((Number) row[0]).longValue();
+ long minutes = ((Number) row[1]).longValue();
+ long cost = ((Number) row[2]).longValue();
+ totals.put(projectId, new long[] {minutes, cost});
+ }
+
+ return projects.stream()
+ .map(
+ p -> {
+ long[] t = totals.getOrDefault(p.getId(), new long[] {0, 0});
+ return ProjectResponse.from(p, t[0], t[1]);
+ })
+ .toList();
+ }
+
+ @Transactional
+ public ProjectResponse create(CreateProjectRequest req) {
+ Project p = new Project();
+ p.setName(req.name());
+ p.setTimeBudgetMinutes(req.timeBudgetMinutes());
+ p.setCostBudgetCents(req.costBudgetCents());
+ p.setHourlyRateCents(req.hourlyRateCents());
+ Project saved = projectRepository.save(p);
+ return ProjectResponse.from(saved, 0, 0);
+ }
+}
diff --git a/backend/src/main/java/com/example/app/report/ReportController.java b/backend/src/main/java/com/example/app/report/ReportController.java
new file mode 100644
index 0000000..4573e5e
--- /dev/null
+++ b/backend/src/main/java/com/example/app/report/ReportController.java
@@ -0,0 +1,26 @@
+package com.example.app.report;
+
+import com.example.app.report.ReportDtos.MonthlyReport;
+import com.example.app.report.ReportDtos.WeeklyReport;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/reports")
+public class ReportController {
+
+ private final ReportService reportService;
+
+ public ReportController(ReportService reportService) {
+ this.reportService = reportService;
+ }
+
+ @GetMapping("/weekly")
+ public WeeklyReport weekly(@RequestParam Long userId, @RequestParam String week) {
+ return reportService.weeklyReport(userId, week);
+ }
+
+ @GetMapping("/monthly")
+ public MonthlyReport monthly(@RequestParam Long userId, @RequestParam String month) {
+ return reportService.monthlyReport(userId, month);
+ }
+}
diff --git a/backend/src/main/java/com/example/app/report/ReportDtos.java b/backend/src/main/java/com/example/app/report/ReportDtos.java
new file mode 100644
index 0000000..58cda3f
--- /dev/null
+++ b/backend/src/main/java/com/example/app/report/ReportDtos.java
@@ -0,0 +1,26 @@
+package com.example.app.report;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public class ReportDtos {
+
+ public record DayReport(LocalDate date, long totalMinutes, long totalCostCents) {}
+
+ public record WeeklyReport(
+ List days, long weekTotalMinutes, long weekTotalCostCents) {}
+
+ public record ProjectMonthReport(
+ Long projectId,
+ String name,
+ long minutes,
+ long costCents,
+ int timeBudgetMinutes,
+ int costBudgetCents,
+ double timeBudgetPct,
+ double costBudgetPct,
+ boolean breached) {}
+
+ public record MonthlyReport(
+ List projects, long monthTotalMinutes, long monthTotalCostCents) {}
+}
diff --git a/backend/src/main/java/com/example/app/report/ReportService.java b/backend/src/main/java/com/example/app/report/ReportService.java
new file mode 100644
index 0000000..c839f63
--- /dev/null
+++ b/backend/src/main/java/com/example/app/report/ReportService.java
@@ -0,0 +1,114 @@
+package com.example.app.report;
+
+import com.example.app.report.ReportDtos.DayReport;
+import com.example.app.report.ReportDtos.MonthlyReport;
+import com.example.app.report.ReportDtos.ProjectMonthReport;
+import com.example.app.report.ReportDtos.WeeklyReport;
+import com.example.app.time.TimeEntryRepository;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.IsoFields;
+import java.time.temporal.TemporalAdjusters;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional(readOnly = true)
+public class ReportService {
+
+ private static final DateTimeFormatter ISO_WEEK_FORMATTER =
+ DateTimeFormatter.ofPattern("YYYY-'W'ww");
+
+ private final TimeEntryRepository timeEntryRepository;
+
+ public ReportService(TimeEntryRepository timeEntryRepository) {
+ this.timeEntryRepository = timeEntryRepository;
+ }
+
+ public WeeklyReport weeklyReport(Long userId, String isoWeek) {
+ LocalDate monday = parseIsoWeekToMonday(isoWeek);
+ LocalDate sunday = monday.plusDays(6);
+
+ Map byDay =
+ timeEntryRepository.aggregateByDay(userId, monday, sunday).stream()
+ .collect(
+ Collectors.toMap(
+ row -> ((java.sql.Date) row[0]).toLocalDate(),
+ row ->
+ new long[] {((Number) row[1]).longValue(), ((Number) row[2]).longValue()}));
+
+ List days = new ArrayList<>();
+ for (int i = 0; i < 7; i++) {
+ LocalDate day = monday.plusDays(i);
+ long[] totals = byDay.getOrDefault(day, new long[] {0, 0});
+ days.add(new DayReport(day, totals[0], totals[1]));
+ }
+
+ long weekMinutes = days.stream().mapToLong(DayReport::totalMinutes).sum();
+ long weekCost = days.stream().mapToLong(DayReport::totalCostCents).sum();
+ return new WeeklyReport(days, weekMinutes, weekCost);
+ }
+
+ public MonthlyReport monthlyReport(Long userId, String yearMonth) {
+ String[] parts = yearMonth.split("-");
+ if (parts.length != 2) {
+ throw new IllegalArgumentException("month must be YYYY-MM, got: " + yearMonth);
+ }
+ int year = Integer.parseInt(parts[0]);
+ int month = Integer.parseInt(parts[1]);
+
+ List projects = new ArrayList<>();
+ long totalMinutes = 0;
+ long totalCost = 0;
+
+ for (Object[] row : timeEntryRepository.aggregateByProjectForMonth(userId, year, month)) {
+ long projectId = ((Number) row[0]).longValue();
+ String name = (String) row[1];
+ int timeBudgetMinutes = ((Number) row[2]).intValue();
+ int costBudgetCents = ((Number) row[3]).intValue();
+ long minutes = ((Number) row[4]).longValue();
+ long costCents = ((Number) row[5]).longValue();
+
+ double timePct = timeBudgetMinutes > 0 ? (minutes * 100.0 / timeBudgetMinutes) : 0;
+ double costPct = costBudgetCents > 0 ? (costCents * 100.0 / costBudgetCents) : 0;
+ boolean breached = minutes > timeBudgetMinutes || costCents > costBudgetCents;
+
+ projects.add(
+ new ProjectMonthReport(
+ projectId,
+ name,
+ minutes,
+ costCents,
+ timeBudgetMinutes,
+ costBudgetCents,
+ timePct,
+ costPct,
+ breached));
+ totalMinutes += minutes;
+ totalCost += costCents;
+ }
+
+ return new MonthlyReport(projects, totalMinutes, totalCost);
+ }
+
+ static LocalDate parseIsoWeekToMonday(String isoWeek) {
+ // Input format: "2026-W20"
+ String[] parts = isoWeek.split("-W");
+ if (parts.length != 2) {
+ throw new IllegalArgumentException("week must be YYYY-Www, got: " + isoWeek);
+ }
+ int year = Integer.parseInt(parts[0]);
+ int week = Integer.parseInt(parts[1]);
+ // ISO week 1 is the week containing the first Thursday of the year
+ LocalDate jan4 = LocalDate.of(year, 1, 4);
+ LocalDate firstMonday = jan4.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
+ // adjust for ISO week number relative to the week containing Jan 4
+ int weekOfJan4 = jan4.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
+ return firstMonday.plusWeeks((long) week - weekOfJan4);
+ }
+}
diff --git a/backend/src/main/java/com/example/app/time/TimeEntry.java b/backend/src/main/java/com/example/app/time/TimeEntry.java
new file mode 100644
index 0000000..81e3eeb
--- /dev/null
+++ b/backend/src/main/java/com/example/app/time/TimeEntry.java
@@ -0,0 +1,35 @@
+package com.example.app.time;
+
+import com.example.app.project.Project;
+import com.example.app.user.User;
+import jakarta.persistence.*;
+import java.time.LocalDate;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(name = "time_entries")
+@Getter
+@Setter
+@NoArgsConstructor
+public class TimeEntry {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ private Project project;
+
+ @Column(name = "entry_date", nullable = false)
+ private LocalDate entryDate;
+
+ @Column(nullable = false)
+ private int durationMinutes;
+}
diff --git a/backend/src/main/java/com/example/app/time/TimeEntryController.java b/backend/src/main/java/com/example/app/time/TimeEntryController.java
new file mode 100644
index 0000000..b0ff9a5
--- /dev/null
+++ b/backend/src/main/java/com/example/app/time/TimeEntryController.java
@@ -0,0 +1,48 @@
+package com.example.app.time;
+
+import com.example.app.time.TimeEntryDtos.CreateTimeEntryRequest;
+import com.example.app.time.TimeEntryDtos.TimeEntryResponse;
+import com.example.app.time.TimeEntryDtos.UpdateTimeEntryRequest;
+import jakarta.validation.Valid;
+import java.time.LocalDate;
+import java.util.List;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/time-entries")
+public class TimeEntryController {
+
+ private final TimeEntryService timeEntryService;
+
+ public TimeEntryController(TimeEntryService timeEntryService) {
+ this.timeEntryService = timeEntryService;
+ }
+
+ @GetMapping
+ public List list(
+ @RequestParam Long userId,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
+ return timeEntryService.findByUserAndRange(userId, from, to);
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public TimeEntryResponse create(@Valid @RequestBody CreateTimeEntryRequest req) {
+ return timeEntryService.create(req);
+ }
+
+ @PutMapping("/{id}")
+ public TimeEntryResponse update(
+ @PathVariable Long id, @Valid @RequestBody UpdateTimeEntryRequest req) {
+ return timeEntryService.update(id, req);
+ }
+
+ @DeleteMapping("/{id}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void delete(@PathVariable Long id) {
+ timeEntryService.delete(id);
+ }
+}
diff --git a/backend/src/main/java/com/example/app/time/TimeEntryDtos.java b/backend/src/main/java/com/example/app/time/TimeEntryDtos.java
new file mode 100644
index 0000000..4180c93
--- /dev/null
+++ b/backend/src/main/java/com/example/app/time/TimeEntryDtos.java
@@ -0,0 +1,41 @@
+package com.example.app.time;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+public class TimeEntryDtos {
+
+ public record CreateTimeEntryRequest(
+ @NotNull Long userId,
+ @NotNull Long projectId,
+ @NotNull LocalDate entryDate,
+ @Min(1) @Max(1440) int durationMinutes) {}
+
+ public record UpdateTimeEntryRequest(
+ @NotNull Long projectId,
+ @NotNull LocalDate entryDate,
+ @Min(1) @Max(1440) int durationMinutes) {}
+
+ public record TimeEntryResponse(
+ Long id,
+ Long userId,
+ String userName,
+ Long projectId,
+ String projectName,
+ LocalDate entryDate,
+ int durationMinutes) {
+
+ public static TimeEntryResponse from(TimeEntry e) {
+ return new TimeEntryResponse(
+ e.getId(),
+ e.getUser().getId(),
+ e.getUser().getName(),
+ e.getProject().getId(),
+ e.getProject().getName(),
+ e.getEntryDate(),
+ e.getDurationMinutes());
+ }
+ }
+}
diff --git a/backend/src/main/java/com/example/app/time/TimeEntryRepository.java b/backend/src/main/java/com/example/app/time/TimeEntryRepository.java
new file mode 100644
index 0000000..1956ac3
--- /dev/null
+++ b/backend/src/main/java/com/example/app/time/TimeEntryRepository.java
@@ -0,0 +1,95 @@
+package com.example.app.time;
+
+import java.time.LocalDate;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface TimeEntryRepository extends JpaRepository {
+
+ @Query(
+ """
+ SELECT e FROM TimeEntry e
+ JOIN FETCH e.user JOIN FETCH e.project
+ WHERE e.user.id = :userId
+ AND e.entryDate >= :from
+ AND e.entryDate <= :to
+ ORDER BY e.entryDate
+ """)
+ List findByUserAndDateRange(
+ @Param("userId") Long userId, @Param("from") LocalDate from, @Param("to") LocalDate to);
+
+ /**
+ * Aggregates minutes and cost per day for a given user and date range. Returns rows of
+ * [entry_date, total_minutes, total_cost_cents].
+ */
+ @Query(
+ value =
+ """
+ SELECT te.entry_date,
+ SUM(te.duration_minutes) AS total_minutes,
+ SUM(te.duration_minutes
+ * COALESCE(p.hourly_rate_cents, u.default_hourly_rate_cents) / 60)
+ AS total_cost_cents
+ FROM time_entries te
+ JOIN projects p ON p.id = te.project_id
+ JOIN users u ON u.id = te.user_id
+ WHERE te.user_id = :userId
+ AND te.entry_date >= :from
+ AND te.entry_date <= :to
+ GROUP BY te.entry_date
+ ORDER BY te.entry_date
+ """,
+ nativeQuery = true)
+ List