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:
dbastrikin
2026-03-09 23:37:58 +02:00
parent 765346d4e4
commit c9ddb708b1
13 changed files with 202 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
-- +goose Up
ALTER TABLE users ADD COLUMN date_of_birth DATE;
UPDATE users
SET date_of_birth = (CURRENT_DATE - (age * INTERVAL '1 year'))::DATE
WHERE age IS NOT NULL;
ALTER TABLE users DROP COLUMN age;
-- +goose Down
ALTER TABLE users ADD COLUMN age SMALLINT;
UPDATE users
SET age = EXTRACT(YEAR FROM AGE(CURRENT_DATE, date_of_birth))::SMALLINT
WHERE date_of_birth IS NOT NULL;
ALTER TABLE users DROP COLUMN date_of_birth;