feat: replace age integer with date_of_birth across backend and client
Store date_of_birth (DATE) instead of a static age integer so that age is always computed dynamically from the stored date of birth. - Migration 011: adds date_of_birth, backfills from age, drops age - AgeFromDOB helper computes current age from YYYY-MM-DD string - User model, repository SQL, and service validation updated - Flutter: User.age becomes a computed getter; profile edit screen uses a date picker bounded to [today-120y, today-10y] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package user
|
||||
|
||||
import "math"
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Activity level multipliers (Mifflin-St Jeor).
|
||||
var activityMultiplier = map[string]float64{
|
||||
@@ -16,6 +19,23 @@ var goalAdjustment = map[string]float64{
|
||||
"gain": 300,
|
||||
}
|
||||
|
||||
// AgeFromDOB computes age in years from a "YYYY-MM-DD" string. Returns nil on error.
|
||||
func AgeFromDOB(dob *string) *int {
|
||||
if dob == nil {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", *dob)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
years := now.Year() - t.Year()
|
||||
if now.Month() < t.Month() || (now.Month() == t.Month() && now.Day() < t.Day()) {
|
||||
years--
|
||||
}
|
||||
return &years
|
||||
}
|
||||
|
||||
// CalculateDailyCalories computes the daily calorie target using the
|
||||
// Mifflin-St Jeor equation. Returns nil if any required parameter is missing.
|
||||
func CalculateDailyCalories(heightCM *int, weightKG *float64, age *int, gender, activity, goal *string) *int {
|
||||
|
||||
@@ -2,10 +2,48 @@ package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestAgeFromDOB_Nil(t *testing.T) {
|
||||
if AgeFromDOB(nil) != nil {
|
||||
t.Fatal("expected nil for nil input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeFromDOB_InvalidFormat(t *testing.T) {
|
||||
s := "not-a-date"
|
||||
if AgeFromDOB(&s) != nil {
|
||||
t.Fatal("expected nil for invalid format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeFromDOB_ExactAge(t *testing.T) {
|
||||
dob := time.Now().AddDate(-30, 0, 0).Format("2006-01-02")
|
||||
age := AgeFromDOB(&dob)
|
||||
if age == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if *age != 30 {
|
||||
t.Errorf("expected 30, got %d", *age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeFromDOB_BeforeBirthday(t *testing.T) {
|
||||
// Birthday is one day in the future relative to today-25y, so age should be 24
|
||||
now := time.Now()
|
||||
dob := time.Date(now.Year()-25, now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
|
||||
age := AgeFromDOB(&dob)
|
||||
if age == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if *age != 24 {
|
||||
t.Errorf("expected 24, got %d", *age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDailyCalories_MaleMaintain(t *testing.T) {
|
||||
cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain"))
|
||||
if cal == nil {
|
||||
|
||||
@@ -13,7 +13,7 @@ type User struct {
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
WeightKG *float64 `json:"weight_kg"`
|
||||
Age *int `json:"age"`
|
||||
DateOfBirth *string `json:"date_of_birth"`
|
||||
Gender *string `json:"gender"`
|
||||
Activity *string `json:"activity"`
|
||||
Goal *string `json:"goal"`
|
||||
@@ -28,7 +28,7 @@ type UpdateProfileRequest struct {
|
||||
Name *string `json:"name"`
|
||||
HeightCM *int `json:"height_cm"`
|
||||
WeightKG *float64 `json:"weight_kg"`
|
||||
Age *int `json:"age"`
|
||||
DateOfBirth *string `json:"date_of_birth"`
|
||||
Gender *string `json:"gender"`
|
||||
Activity *string `json:"activity"`
|
||||
Goal *string `json:"goal"`
|
||||
@@ -39,6 +39,6 @@ type UpdateProfileRequest struct {
|
||||
// HasBodyParams returns true if any body parameter is being updated
|
||||
// that would require recalculation of daily calories.
|
||||
func (r *UpdateProfileRequest) HasBodyParams() bool {
|
||||
return r.HeightCM != nil || r.WeightKG != nil || r.Age != nil ||
|
||||
return r.HeightCM != nil || r.WeightKG != nil || r.DateOfBirth != nil ||
|
||||
r.Gender != nil || r.Activity != nil || r.Goal != nil
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = now()
|
||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at`
|
||||
|
||||
return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr))
|
||||
@@ -54,7 +54,7 @@ func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name,
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
|
||||
query := `
|
||||
SELECT id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at
|
||||
FROM users WHERE id = $1`
|
||||
|
||||
@@ -91,9 +91,9 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{
|
||||
args = append(args, *req.WeightKG)
|
||||
argIdx++
|
||||
}
|
||||
if req.Age != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("age = $%d", argIdx))
|
||||
args = append(args, *req.Age)
|
||||
if req.DateOfBirth != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("date_of_birth = $%d", argIdx))
|
||||
args = append(args, *req.DateOfBirth)
|
||||
argIdx++
|
||||
}
|
||||
if req.Gender != nil {
|
||||
@@ -132,7 +132,7 @@ func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{
|
||||
UPDATE users SET %s
|
||||
WHERE id = $%d
|
||||
RETURNING id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at`,
|
||||
strings.Join(setClauses, ", "), argIdx)
|
||||
args = append(args, id)
|
||||
@@ -184,7 +184,7 @@ func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expi
|
||||
func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) {
|
||||
query := `
|
||||
SELECT id, firebase_uid, email, name, avatar_url,
|
||||
height_cm, weight_kg, age, gender, activity, goal, daily_calories,
|
||||
height_cm, weight_kg, date_of_birth, gender, activity, goal, daily_calories,
|
||||
plan, preferences, created_at, updated_at
|
||||
FROM users
|
||||
WHERE refresh_token = $1 AND token_expires_at > now()`
|
||||
@@ -205,14 +205,19 @@ type scannable interface {
|
||||
func (r *Repository) scanUser(row scannable) (*User, error) {
|
||||
var u User
|
||||
var prefs []byte
|
||||
var dob *time.Time
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL,
|
||||
&u.HeightCM, &u.WeightKG, &u.Age, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories,
|
||||
&u.HeightCM, &u.WeightKG, &dob, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories,
|
||||
&u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
if dob != nil {
|
||||
s := dob.Format("2006-01-02")
|
||||
u.DateOfBirth = &s
|
||||
}
|
||||
u.Preferences = json.RawMessage(prefs)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -100,17 +100,17 @@ func TestRepository_Update_MultipleFields(t *testing.T) {
|
||||
created, _ := repo.UpsertByFirebaseUID(ctx, "fb-multi", "multi@example.com", "Multi User", "")
|
||||
height := 175
|
||||
weight := 70.5
|
||||
age := 25
|
||||
dob := "2001-03-09"
|
||||
gender := "male"
|
||||
activity := "moderate"
|
||||
goal := "maintain"
|
||||
u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{
|
||||
HeightCM: &height,
|
||||
WeightKG: &weight,
|
||||
Age: &age,
|
||||
Gender: &gender,
|
||||
Activity: &activity,
|
||||
Goal: &goal,
|
||||
HeightCM: &height,
|
||||
WeightKG: &weight,
|
||||
DateOfBirth: &dob,
|
||||
Gender: &gender,
|
||||
Activity: &activity,
|
||||
Goal: &goal,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -46,10 +47,11 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdatePr
|
||||
if req.WeightKG != nil {
|
||||
weight = req.WeightKG
|
||||
}
|
||||
age := current.Age
|
||||
if req.Age != nil {
|
||||
age = req.Age
|
||||
dob := current.DateOfBirth
|
||||
if req.DateOfBirth != nil {
|
||||
dob = req.DateOfBirth
|
||||
}
|
||||
age := AgeFromDOB(dob)
|
||||
gender := current.Gender
|
||||
if req.Gender != nil {
|
||||
gender = req.Gender
|
||||
@@ -89,9 +91,17 @@ func validateProfileRequest(req UpdateProfileRequest) error {
|
||||
return fmt.Errorf("weight_kg must be between 30 and 300")
|
||||
}
|
||||
}
|
||||
if req.Age != nil {
|
||||
if *req.Age < 10 || *req.Age > 120 {
|
||||
return fmt.Errorf("age must be between 10 and 120")
|
||||
if req.DateOfBirth != nil {
|
||||
t, err := time.Parse("2006-01-02", *req.DateOfBirth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("date_of_birth must be in YYYY-MM-DD format")
|
||||
}
|
||||
if t.After(time.Now()) {
|
||||
return fmt.Errorf("date_of_birth cannot be in the future")
|
||||
}
|
||||
age := AgeFromDOB(req.DateOfBirth)
|
||||
if *age < 10 || *age > 120 {
|
||||
return fmt.Errorf("date_of_birth must yield an age between 10 and 120")
|
||||
}
|
||||
}
|
||||
if req.Gender != nil {
|
||||
|
||||
@@ -94,19 +94,20 @@ func TestUpdateProfile_NameOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) {
|
||||
dob := time.Now().AddDate(-30, 0, 0).Format("2006-01-02")
|
||||
profileReq := UpdateProfileRequest{
|
||||
HeightCM: ptrInt(180),
|
||||
WeightKG: ptrFloat(80),
|
||||
Age: ptrInt(30),
|
||||
Gender: ptrStr("male"),
|
||||
Activity: ptrStr("moderate"),
|
||||
Goal: ptrStr("maintain"),
|
||||
HeightCM: ptrInt(180),
|
||||
WeightKG: ptrFloat(80),
|
||||
DateOfBirth: &dob,
|
||||
Gender: ptrStr("male"),
|
||||
Activity: ptrStr("moderate"),
|
||||
Goal: ptrStr("maintain"),
|
||||
}
|
||||
finalUser := &User{
|
||||
ID: "user-1",
|
||||
HeightCM: ptrInt(180),
|
||||
WeightKG: ptrFloat(80),
|
||||
Age: ptrInt(30),
|
||||
DateOfBirth: &dob,
|
||||
Gender: ptrStr("male"),
|
||||
Activity: ptrStr("moderate"),
|
||||
Goal: ptrStr("maintain"),
|
||||
@@ -178,19 +179,31 @@ func TestUpdateProfile_InvalidWeight_TooHigh(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidAge_TooLow(t *testing.T) {
|
||||
func TestUpdateProfile_InvalidDOB_TooRecent(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
dob := time.Now().AddDate(-5, 0, 0).Format("2006-01-02")
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(5)})
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{DateOfBirth: &dob})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidAge_TooHigh(t *testing.T) {
|
||||
func TestUpdateProfile_InvalidDOB_TooOld(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
dob := time.Now().AddDate(-150, 0, 0).Format("2006-01-02")
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(150)})
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{DateOfBirth: &dob})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_InvalidDOB_BadFormat(t *testing.T) {
|
||||
svc := NewService(&mockUserRepo{})
|
||||
dob := "not-a-date"
|
||||
|
||||
_, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{DateOfBirth: &dob})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user