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,14 +100,14 @@ 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,
DateOfBirth: &dob,
Gender: &gender,
Activity: &activity,
Goal: &goal,

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,10 +94,11 @@ 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),
DateOfBirth: &dob,
Gender: ptrStr("male"),
Activity: ptrStr("moderate"),
Goal: ptrStr("maintain"),
@@ -106,7 +107,7 @@ func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) {
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;

View File

@@ -336,7 +336,7 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
late final TextEditingController _nameCtrl;
late final TextEditingController _heightCtrl;
late final TextEditingController _weightCtrl;
late final TextEditingController _ageCtrl;
DateTime? _selectedDob;
String? _gender;
String? _goal;
String? _activity;
@@ -351,7 +351,8 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
_heightCtrl = TextEditingController(text: u.heightCm?.toString() ?? '');
_weightCtrl = TextEditingController(
text: u.weightKg != null ? _fmt(u.weightKg!) : '');
_ageCtrl = TextEditingController(text: u.age?.toString() ?? '');
_selectedDob =
u.dateOfBirth != null ? DateTime.tryParse(u.dateOfBirth!) : null;
_gender = u.gender;
_goal = u.goal;
_activity = u.activity;
@@ -363,13 +364,24 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
_nameCtrl.dispose();
_heightCtrl.dispose();
_weightCtrl.dispose();
_ageCtrl.dispose();
super.dispose();
}
String _fmt(double w) =>
w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1);
Future<void> _pickDob() async {
final now = DateTime.now();
final initial = _selectedDob ?? DateTime(now.year - 25, now.month, now.day);
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(now.year - 120),
lastDate: DateTime(now.year - 10, now.month, now.day),
);
if (picked != null) setState(() => _selectedDob = picked);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
@@ -378,7 +390,11 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
name: _nameCtrl.text.trim(),
heightCm: int.tryParse(_heightCtrl.text),
weightKg: double.tryParse(_weightCtrl.text),
age: int.tryParse(_ageCtrl.text),
dateOfBirth: _selectedDob != null
? '${_selectedDob!.year.toString().padLeft(4, '0')}-'
'${_selectedDob!.month.toString().padLeft(2, '0')}-'
'${_selectedDob!.day.toString().padLeft(2, '0')}'
: null,
gender: _gender,
goal: _goal,
activity: _activity,
@@ -506,21 +522,21 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
),
const SizedBox(height: 12),
// Age
TextFormField(
controller: _ageCtrl,
// Date of birth
InkWell(
onTap: _pickDob,
child: InputDecorator(
decoration:
const InputDecoration(labelText: 'Возраст (лет)'),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
validator: (v) {
if (v == null || v.isEmpty) return null;
final n = int.tryParse(v);
if (n == null || n < 10 || n > 120) return '10120';
return null;
},
const InputDecoration(labelText: 'Дата рождения'),
child: Text(
_selectedDob != null
? '${_selectedDob!.day.toString().padLeft(2, '0')}.'
'${_selectedDob!.month.toString().padLeft(2, '0')}.'
'${_selectedDob!.year}'
: 'Не задано',
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
const SizedBox(height: 20),

View File

@@ -12,7 +12,7 @@ class UpdateProfileRequest {
final String? name;
final int? heightCm;
final double? weightKg;
final int? age;
final String? dateOfBirth;
final String? gender;
final String? activity;
final String? goal;
@@ -22,7 +22,7 @@ class UpdateProfileRequest {
this.name,
this.heightCm,
this.weightKg,
this.age,
this.dateOfBirth,
this.gender,
this.activity,
this.goal,
@@ -34,7 +34,7 @@ class UpdateProfileRequest {
if (name != null) map['name'] = name;
if (heightCm != null) map['height_cm'] = heightCm;
if (weightKg != null) map['weight_kg'] = weightKg;
if (age != null) map['age'] = age;
if (dateOfBirth != null) map['date_of_birth'] = dateOfBirth;
if (gender != null) map['gender'] = gender;
if (activity != null) map['activity'] = activity;
if (goal != null) map['goal'] = goal;

View File

@@ -13,7 +13,8 @@ class User {
final int? heightCm;
@JsonKey(name: 'weight_kg')
final double? weightKg;
final int? age;
@JsonKey(name: 'date_of_birth')
final String? dateOfBirth;
final String? gender;
final String? activity;
final String? goal;
@@ -30,7 +31,7 @@ class User {
this.avatarUrl,
this.heightCm,
this.weightKg,
this.age,
this.dateOfBirth,
this.gender,
this.activity,
this.goal,
@@ -42,6 +43,19 @@ class User {
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
bool get hasCompletedOnboarding =>
heightCm != null && weightKg != null && age != null && gender != null;
int? get age {
if (dateOfBirth == null) return null;
final dob = DateTime.tryParse(dateOfBirth!);
if (dob == null) return null;
final now = DateTime.now();
int years = now.year - dob.year;
if (now.month < dob.month ||
(now.month == dob.month && now.day < dob.day)) {
years--;
}
return years;
}
bool get hasCompletedOnboarding =>
heightCm != null && weightKg != null && dateOfBirth != null && gender != null;
}

View File

@@ -13,7 +13,7 @@ User _$UserFromJson(Map<String, dynamic> json) => User(
avatarUrl: json['avatar_url'] as String?,
heightCm: (json['height_cm'] as num?)?.toInt(),
weightKg: (json['weight_kg'] as num?)?.toDouble(),
age: (json['age'] as num?)?.toInt(),
dateOfBirth: json['date_of_birth'] as String?,
gender: json['gender'] as String?,
activity: json['activity'] as String?,
goal: json['goal'] as String?,
@@ -29,7 +29,7 @@ Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'avatar_url': instance.avatarUrl,
'height_cm': instance.heightCm,
'weight_kg': instance.weightKg,
'age': instance.age,
'date_of_birth': instance.dateOfBirth,
'gender': instance.gender,
'activity': instance.activity,
'goal': instance.goal,

View File

@@ -125,10 +125,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -668,26 +668,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -993,10 +993,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.6"
typed_data:
dependency: transitive
description: