feat: Phase 4 — 9 new features (v1.1)
Some checks failed
CI / Build & test backend (push) Failing after 14m56s
Some checks failed
CI / Build & test backend (push) Failing after 14m56s
REQ-MOB-010: BarcodeScreen.tsx — barcode scanner via react-native-camera REQ-VIZ-001: WeeklyCalorieChart.tsx — 7-day bar chart on History screen REQ-VIZ-002: Streak tracker — GET /meals/streak + HomeScreen badge REQ-UX-001: Quick-add calories — POST /meals/quick-add + QuickAddScreen REQ-UX-002: Food favourites — UserFoodMemory.favourite + toggle endpoint + FoodRow star REQ-UX-003: GoalBanner.tsx — in-app slide-in when daily target hit REQ-EXP-001: ExportController — GET /export/meals CSV download REQ-WTR-001: Water tracking — WaterEntry entity + POST/GET /water + DailyDetails widget REQ-UX-004: Daily logging reminder — HomeScreen after-18:00 banner Also: Flyway V2 (favourite), V3 (water_entries), V4 (source constraints) Traceability, CHANGELOG, PLAN updated after each feature
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.controller;
|
||||
|
||||
import com.caloriecounter.entity.MealEntry;
|
||||
import com.caloriecounter.entity.MealItem;
|
||||
import com.caloriecounter.repository.MealEntryRepository;
|
||||
import com.caloriecounter.security.SecurityUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Data export endpoint.
|
||||
* Returns meal entries as CSV for download / sharing.
|
||||
* REQ-EXP-001
|
||||
*
|
||||
* Security: user data isolation is enforced by querying only records
|
||||
* belonging to the authenticated user's ID.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/export")
|
||||
@RequiredArgsConstructor
|
||||
public class ExportController {
|
||||
|
||||
private final MealEntryRepository mealEntryRepository;
|
||||
|
||||
/**
|
||||
* Exports meal entries between two dates as a CSV file.
|
||||
* Maximum range: 365 days to prevent OOM on large data sets.
|
||||
*
|
||||
* @param from inclusive start date (YYYY-MM-DD)
|
||||
* @param to inclusive end date (YYYY-MM-DD)
|
||||
* @return CSV with columns: date, mealType, foodName, grams, calories, source
|
||||
*/
|
||||
@GetMapping(value = "/meals", produces = "text/csv")
|
||||
public ResponseEntity<byte[]> exportMeals(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) throws IOException {
|
||||
|
||||
if (from.isAfter(to) || to.minusDays(365).isAfter(from)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
List<MealEntry> entries = mealEntryRepository
|
||||
.findByUserIdAndDateBetween(SecurityUtils.currentUserId(), from, to);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (PrintWriter writer = new PrintWriter(baos, true, StandardCharsets.UTF_8)) {
|
||||
writer.println("date,mealType,foodName,grams,calories,source");
|
||||
for (MealEntry entry : entries) {
|
||||
for (MealItem item : entry.getItems()) {
|
||||
writer.printf("%s,%s,\"%s\",%s,%s,%s%n",
|
||||
entry.getDate(),
|
||||
entry.getMealType().name(),
|
||||
csvEscape(item.getFoodItem().getName()),
|
||||
item.getQuantityGrams().toPlainString(),
|
||||
item.getCalories().toPlainString(),
|
||||
entry.getSource().name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byte[] csv = baos.toByteArray();
|
||||
String filename = "calories_" + from + "_" + to + ".csv";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv"))
|
||||
.contentLength(csv.length)
|
||||
.body(csv);
|
||||
}
|
||||
|
||||
/** Escapes double-quotes in a CSV field value by doubling them. */
|
||||
private static String csvEscape(String value) {
|
||||
return value == null ? "" : value.replace("\"", "\"\"");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package com.caloriecounter.controller;
|
||||
|
||||
import com.caloriecounter.dto.food.FoodItemDto;
|
||||
import com.caloriecounter.security.SecurityUtils;
|
||||
import com.caloriecounter.service.FoodService;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
@@ -11,6 +12,8 @@ import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Food catalogue endpoints — require JWT.
|
||||
@@ -45,4 +48,24 @@ public class FoodController {
|
||||
message = "Barcode must be 8–14 digits") String code) {
|
||||
return ResponseEntity.ok(foodService.findByBarcode(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all food items the user has starred, ordered by most recently used.
|
||||
* REQ-UX-002
|
||||
*/
|
||||
@GetMapping("/favourites")
|
||||
public ResponseEntity<List<FoodItemDto>> getFavourites() {
|
||||
return ResponseEntity.ok(foodService.getFavourites(SecurityUtils.currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the favourite flag for a given food item.
|
||||
* Returns {"favourite": true|false} reflecting the new state.
|
||||
* REQ-UX-002
|
||||
*/
|
||||
@PostMapping("/{id}/favourite")
|
||||
public ResponseEntity<Map<String, Boolean>> toggleFavourite(@PathVariable UUID id) {
|
||||
boolean newState = foodService.toggleFavourite(SecurityUtils.currentUserId(), id);
|
||||
return ResponseEntity.ok(Map.of("favourite", newState));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,23 @@ public class MealController {
|
||||
mealService.deleteMeal(SecurityUtils.currentUserId(), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current and longest streak of consecutive logged days.
|
||||
* REQ-VIZ-002
|
||||
*/
|
||||
@GetMapping("/streak")
|
||||
public ResponseEntity<MealService.StreakResponse> getStreak() {
|
||||
return ResponseEntity.ok(mealService.getStreak(SecurityUtils.currentUserId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs calories directly without a food search — creates a system food item on demand.
|
||||
* REQ-UX-001
|
||||
*/
|
||||
@PostMapping("/quick-add")
|
||||
public ResponseEntity<MealEntryDto> quickAdd(@Valid @RequestBody MealService.QuickAddRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(mealService.quickAddMeal(SecurityUtils.currentUserId(), request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.controller;
|
||||
|
||||
import com.caloriecounter.entity.User;
|
||||
import com.caloriecounter.entity.WaterEntry;
|
||||
import com.caloriecounter.repository.UserRepository;
|
||||
import com.caloriecounter.repository.WaterEntryRepository;
|
||||
import com.caloriecounter.security.SecurityUtils;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Water intake endpoints — require JWT.
|
||||
* REQ-WTR-001
|
||||
*
|
||||
* Security: all queries are scoped to the authenticated user's ID.
|
||||
*/
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/water")
|
||||
@RequiredArgsConstructor
|
||||
public class WaterController {
|
||||
|
||||
private final WaterEntryRepository waterEntryRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Returns the total water consumed on a given day (in ml).
|
||||
* Response: {"date": "YYYY-MM-DD", "totalMl": 1750}
|
||||
*/
|
||||
@GetMapping("/daily")
|
||||
public ResponseEntity<Map<String, Object>> getDailyTotal(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
int total = waterEntryRepository.sumAmountMlByUserIdAndDate(SecurityUtils.currentUserId(), date);
|
||||
return ResponseEntity.ok(Map.of("date", date.toString(), "totalMl", total));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a water intake event.
|
||||
* Request body: {"date": "YYYY-MM-DD", "amountMl": 250}
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> logWater(@Validated @RequestBody LogWaterRequest request) {
|
||||
User user = userRepository.getReferenceById(SecurityUtils.currentUserId());
|
||||
WaterEntry entry = WaterEntry.builder()
|
||||
.user(user)
|
||||
.date(request.date())
|
||||
.amountMl(request.amountMl())
|
||||
.build();
|
||||
waterEntryRepository.save(entry);
|
||||
|
||||
int newTotal = waterEntryRepository.sumAmountMlByUserIdAndDate(
|
||||
SecurityUtils.currentUserId(), request.date());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(Map.of("date", request.date().toString(), "totalMl", newTotal));
|
||||
}
|
||||
|
||||
/** Request body record for POST /water. */
|
||||
public record LogWaterRequest(
|
||||
@NotNull LocalDate date,
|
||||
@Min(1) @Max(5000) int amountMl) {}
|
||||
}
|
||||
@@ -54,6 +54,6 @@ public class FoodItem {
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public enum Source {
|
||||
openfoodfacts, custom, ai
|
||||
openfoodfacts, custom, ai, quickadd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,6 @@ public class MealEntry {
|
||||
}
|
||||
|
||||
public enum LogSource {
|
||||
manual, barcode, photo
|
||||
manual, barcode, photo, quickadd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,9 @@ public class UserFoodMemory {
|
||||
|
||||
@Column(nullable = false)
|
||||
private OffsetDateTime lastUsed;
|
||||
|
||||
/** Whether the user has starred this food item for quick access. REQ-UX-002 */
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private boolean favourite = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Records a single water intake event for a user on a given day.
|
||||
* Multiple entries per day are allowed — the service sums them for the daily total.
|
||||
* REQ-WTR-001
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "water_entries")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class WaterEntry {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate date;
|
||||
|
||||
/** Amount of water in millilitres (1–5000). */
|
||||
@Column(name = "amount_ml", nullable = false)
|
||||
private int amountMl;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "logged_at", nullable = false, updatable = false)
|
||||
private OffsetDateTime loggedAt;
|
||||
}
|
||||
@@ -19,4 +19,8 @@ public interface MealEntryRepository extends JpaRepository<MealEntry, UUID> {
|
||||
List<MealEntry> findByUserIdAndDateBetween(@Param("userId") UUID userId,
|
||||
@Param("from") LocalDate from,
|
||||
@Param("to") LocalDate to);
|
||||
|
||||
/** Returns all distinct dates on which the user has logged at least one meal, ordered newest first. */
|
||||
@Query("SELECT DISTINCT m.date FROM MealEntry m WHERE m.user.id = :userId ORDER BY m.date DESC")
|
||||
List<LocalDate> findDistinctDatesByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ public interface UserFoodMemoryRepository extends JpaRepository<UserFoodMemory,
|
||||
Optional<UserFoodMemory> findByUserIdAndFoodName(UUID userId, String foodName);
|
||||
|
||||
List<UserFoodMemory> findByUserIdOrderByLastUsedDesc(UUID userId);
|
||||
|
||||
/** Returns all food items starred by the user, ordered by most recently used. */
|
||||
List<UserFoodMemory> findByUserIdAndFavouriteTrueOrderByLastUsedDesc(UUID userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Generated by GitHub Copilot
|
||||
package com.caloriecounter.repository;
|
||||
|
||||
import com.caloriecounter.entity.WaterEntry;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** JPA repository for {@link WaterEntry}. */
|
||||
public interface WaterEntryRepository extends JpaRepository<WaterEntry, UUID> {
|
||||
|
||||
List<WaterEntry> findByUserIdAndDateOrderByLoggedAtAsc(UUID userId, LocalDate date);
|
||||
|
||||
/** Returns the sum of all water logged by the user on a given day (in ml). */
|
||||
@Query("SELECT COALESCE(SUM(w.amountMl), 0) FROM WaterEntry w WHERE w.user.id = :userId AND w.date = :date")
|
||||
int sumAmountMlByUserIdAndDate(@Param("userId") UUID userId, @Param("date") LocalDate date);
|
||||
}
|
||||
@@ -3,13 +3,19 @@ package com.caloriecounter.service;
|
||||
|
||||
import com.caloriecounter.dto.food.FoodItemDto;
|
||||
import com.caloriecounter.entity.FoodItem;
|
||||
import com.caloriecounter.entity.User;
|
||||
import com.caloriecounter.entity.UserFoodMemory;
|
||||
import com.caloriecounter.exception.NotFoundException;
|
||||
import com.caloriecounter.repository.FoodItemRepository;
|
||||
import com.caloriecounter.repository.UserFoodMemoryRepository;
|
||||
import com.caloriecounter.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -25,6 +31,8 @@ public class FoodService {
|
||||
|
||||
private final FoodItemRepository foodItemRepository;
|
||||
private final OpenFoodFactsClient openFoodFactsClient;
|
||||
private final UserFoodMemoryRepository userFoodMemoryRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Searches the local food catalogue. If fewer than 3 local results are found,
|
||||
@@ -80,4 +88,46 @@ public class FoodService {
|
||||
f.getProteinG(), f.getFatG(), f.getCarbsG()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all food items the user has starred, ordered by most recently used.
|
||||
* REQ-UX-002
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<FoodItemDto> getFavourites(UUID userId) {
|
||||
return userFoodMemoryRepository
|
||||
.findByUserIdAndFavouriteTrueOrderByLastUsedDesc(userId)
|
||||
.stream()
|
||||
.map(mem -> foodItemRepository.searchByName(mem.getFoodName())
|
||||
.stream().findFirst().map(this::toDto).orElse(null))
|
||||
.filter(dto -> dto != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the favourite flag for a food item.
|
||||
* If no memory entry exists yet, creates one (avgPortionGrams defaults to 100g).
|
||||
* REQ-UX-002
|
||||
*
|
||||
* @return true if the item is now a favourite, false if unfavourited
|
||||
*/
|
||||
@Transactional
|
||||
public boolean toggleFavourite(UUID userId, UUID foodId) {
|
||||
FoodItem food = foodItemRepository.findById(foodId)
|
||||
.orElseThrow(() -> new NotFoundException("Food item not found: " + foodId));
|
||||
User user = userRepository.getReferenceById(userId);
|
||||
|
||||
UserFoodMemory memory = userFoodMemoryRepository
|
||||
.findByUserIdAndFoodName(userId, food.getName())
|
||||
.orElseGet(() -> UserFoodMemory.builder()
|
||||
.user(user)
|
||||
.foodName(food.getName())
|
||||
.avgPortionGrams(BigDecimal.valueOf(100))
|
||||
.lastUsed(OffsetDateTime.now())
|
||||
.build());
|
||||
|
||||
memory.setFavourite(!memory.isFavourite());
|
||||
userFoodMemoryRepository.save(memory);
|
||||
return memory.isFavourite();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.caloriecounter.dto.meal.*;
|
||||
import com.caloriecounter.entity.*;
|
||||
import com.caloriecounter.exception.ForbiddenException;
|
||||
import com.caloriecounter.exception.NotFoundException;
|
||||
import com.caloriecounter.repository.FoodItemRepository;
|
||||
import com.caloriecounter.repository.MealEntryRepository;
|
||||
import com.caloriecounter.repository.UserRepository;
|
||||
import com.caloriecounter.repository.UserFoodMemoryRepository;
|
||||
@@ -32,6 +33,7 @@ public class MealService {
|
||||
private final MealEntryRepository mealEntryRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final FoodService foodService;
|
||||
private final FoodItemRepository foodItemRepository;
|
||||
private final UserFoodMemoryRepository userFoodMemoryRepository;
|
||||
|
||||
/**
|
||||
@@ -114,6 +116,110 @@ public class MealService {
|
||||
mealEntryRepository.delete(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates current and longest streak of consecutive logged days.
|
||||
* A day counts if the user logged at least one meal on that date.
|
||||
* REQ-VIZ-002
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public StreakResponse getStreak(UUID userId) {
|
||||
List<LocalDate> dates = mealEntryRepository.findDistinctDatesByUserId(userId);
|
||||
if (dates.isEmpty()) {
|
||||
return new StreakResponse(0, 0);
|
||||
}
|
||||
|
||||
LocalDate today = LocalDate.now();
|
||||
int current = 0;
|
||||
int longest = 0;
|
||||
int running = 0;
|
||||
LocalDate expected = dates.get(0);
|
||||
|
||||
// current streak: walk backwards from today
|
||||
for (LocalDate d : dates) {
|
||||
if (d.equals(today.minusDays(current))) {
|
||||
current++;
|
||||
} else if (current == 0 && d.equals(today.minusDays(1))) {
|
||||
// started yesterday — still active
|
||||
current++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// longest streak: single pass over sorted dates
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
if (i == 0 || dates.get(i - 1).minusDays(1).equals(dates.get(i))) {
|
||||
running++;
|
||||
} else {
|
||||
running = 1;
|
||||
}
|
||||
longest = Math.max(longest, running);
|
||||
}
|
||||
|
||||
return new StreakResponse(current, longest);
|
||||
}
|
||||
|
||||
/** Immutable response record for streak data. */
|
||||
public record StreakResponse(int currentStreak, int longestStreak) {}
|
||||
|
||||
/**
|
||||
* Creates a meal entry from a raw calorie amount without requiring a food search.
|
||||
* Finds or creates a system food item named after the label (or "Quick Add") with
|
||||
* 1 kcal/g so that grams == calories for simple arithmetic.
|
||||
* REQ-UX-001
|
||||
*/
|
||||
@Transactional
|
||||
public MealEntryDto quickAddMeal(UUID userId, QuickAddRequest request) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new NotFoundException("User not found"));
|
||||
|
||||
String foodName = (request.label() != null && !request.label().isBlank())
|
||||
? request.label().strip()
|
||||
: "Quick Add";
|
||||
|
||||
// Find or create the system food item (1 kcal/g)
|
||||
FoodItem quickFood = foodItemRepository
|
||||
.searchByName(foodName)
|
||||
.stream()
|
||||
.filter(f -> f.getSource() == FoodItem.Source.quickadd && f.getName().equalsIgnoreCase(foodName))
|
||||
.findFirst()
|
||||
.orElseGet(() -> foodItemRepository.save(FoodItem.builder()
|
||||
.name(foodName)
|
||||
.source(FoodItem.Source.quickadd)
|
||||
.caloriesPer100g(BigDecimal.valueOf(100)) // 1 kcal/g → 100 kcal/100g
|
||||
.proteinG(BigDecimal.ZERO)
|
||||
.fatG(BigDecimal.ZERO)
|
||||
.carbsG(BigDecimal.ZERO)
|
||||
.build()));
|
||||
|
||||
// grams = calories because caloriesPer100g = 100
|
||||
BigDecimal grams = BigDecimal.valueOf(request.calories());
|
||||
|
||||
MealEntry entry = MealEntry.builder()
|
||||
.user(user)
|
||||
.date(request.date())
|
||||
.mealType(request.mealType())
|
||||
.source(MealEntry.LogSource.quickadd)
|
||||
.build();
|
||||
|
||||
MealItem item = MealItem.builder()
|
||||
.mealEntry(entry)
|
||||
.foodItem(quickFood)
|
||||
.quantityGrams(grams)
|
||||
.calories(BigDecimal.valueOf(request.calories()))
|
||||
.build();
|
||||
entry.getItems().add(item);
|
||||
|
||||
return toDto(mealEntryRepository.save(entry));
|
||||
}
|
||||
|
||||
/** Request record for quick-add calories endpoint. */
|
||||
public record QuickAddRequest(
|
||||
@jakarta.validation.constraints.NotNull java.time.LocalDate date,
|
||||
@jakarta.validation.constraints.NotNull MealEntry.MealType mealType,
|
||||
@jakarta.validation.constraints.Min(1) @jakarta.validation.constraints.Max(9999) int calories,
|
||||
@jakarta.validation.constraints.Size(max = 100) String label) {}
|
||||
|
||||
// --- private helpers ---
|
||||
|
||||
private MealEntry findAndCheckOwnership(UUID userId, UUID mealId) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Generated by GitHub Copilot
|
||||
-- V2: Add favourite flag to user_food_memory
|
||||
-- REQ-UX-002: allows users to star food items for quick access in search
|
||||
|
||||
ALTER TABLE user_food_memory
|
||||
ADD COLUMN IF NOT EXISTS favourite BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_food_memory_favourite
|
||||
ON user_food_memory (user_id, favourite)
|
||||
WHERE favourite = TRUE;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Generated by GitHub Copilot
|
||||
-- V3: Water intake tracking
|
||||
-- REQ-WTR-001: stores daily water intake entries per user
|
||||
|
||||
CREATE TABLE water_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
amount_ml INTEGER NOT NULL CHECK (amount_ml > 0 AND amount_ml <= 5000),
|
||||
logged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_water_entries_user_date ON water_entries (user_id, date);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Generated by GitHub Copilot
|
||||
-- V4: Extend CHECK constraints for new source values (quickadd)
|
||||
-- REQ-UX-001: quick-add creates food items with source='quickadd'
|
||||
-- REQ-UX-001: quick-add creates meal entries with source='quickadd'
|
||||
|
||||
-- Drop and re-add food_items source constraint
|
||||
ALTER TABLE food_items
|
||||
DROP CONSTRAINT IF EXISTS food_items_source_check;
|
||||
ALTER TABLE food_items
|
||||
ADD CONSTRAINT food_items_source_check
|
||||
CHECK (source IN ('openfoodfacts','custom','ai','quickadd'));
|
||||
|
||||
-- Drop and re-add meal_entries source constraint
|
||||
ALTER TABLE meal_entries
|
||||
DROP CONSTRAINT IF EXISTS meal_entries_source_check;
|
||||
ALTER TABLE meal_entries
|
||||
ADD CONSTRAINT meal_entries_source_check
|
||||
CHECK (source IN ('manual','barcode','photo','quickadd'));
|
||||
Reference in New Issue
Block a user