feat: Phase 4 — 9 new features (v1.1)
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:
2026-05-19 02:11:23 +03:00
parent 904f1c43b3
commit 12820632e7
46 changed files with 8151 additions and 63 deletions

View File

@@ -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("\"", "\"\"");
}
}

View File

@@ -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 814 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));
}
}

View File

@@ -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));
}
}

View File

@@ -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) {}
}

View File

@@ -54,6 +54,6 @@ public class FoodItem {
private OffsetDateTime createdAt;
public enum Source {
openfoodfacts, custom, ai
openfoodfacts, custom, ai, quickadd
}
}

View File

@@ -61,6 +61,6 @@ public class MealEntry {
}
public enum LogSource {
manual, barcode, photo
manual, barcode, photo, quickadd
}
}

View File

@@ -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;
}

View File

@@ -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 (15000). */
@Column(name = "amount_ml", nullable = false)
private int amountMl;
@CreationTimestamp
@Column(name = "logged_at", nullable = false, updatable = false)
private OffsetDateTime loggedAt;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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'));