Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/components/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
from datetime import UTC, datetime
from datetime import date as date_type
from datetime import time as time_type
from zoneinfo import ZoneInfo

_JST = ZoneInfo("Asia/Tokyo")
from health_intelligence.local_time import APP_TIMEZONE as _JST
from health_intelligence.local_time import local_today


def format_datetime_jst(dt: datetime) -> str:
Expand Down Expand Up @@ -49,3 +49,21 @@ def jst_time_of(dt: datetime) -> time_type:
編集フォームの`st.time_input`初期値として使う。
"""
return dt.astimezone(_JST).time()


def jst_today() -> date_type:
"""日本時間での今日の日付。`st.date_input`の初期値として使う。

`date.today()`は実行環境(Streamlit Community CloudはUTC)の
タイムゾーンに依存し、JST 0:00〜8:59の間は前日になってしまう。
"""
return local_today()


def jst_now_time() -> time_type:
"""日本時間での現在時刻(秒以下切り捨て)。`st.time_input`の初期値として使う。

`datetime.now().time()`は実行環境のタイムゾーン(UTC)の時刻になり、
`combine_jst_to_utc`でJSTとして解釈されると9時間ずれて保存される。
"""
return jst_time_of(datetime.now(UTC)).replace(second=0, microsecond=0)
14 changes: 8 additions & 6 deletions app/pages/today.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
combine_jst_to_utc,
format_datetime_jst,
jst_date_of,
jst_now_time,
jst_time_of,
jst_today,
)
from components.goal_task_labels import ( # noqa: E402
DURATION_OPTIONS,
Expand Down Expand Up @@ -350,7 +352,7 @@ def _render_workout_new_record_section(
exercise_templates = frequent_exercise_templates(all_workout_logs)

workout_date = st.date_input(t("日付"), value=journal_date_value, key="today_workout_date")
workout_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_workout_time")
workout_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_workout_time")

quick_workout_col1, quick_workout_col2 = st.columns([1, 2])
with quick_workout_col1:
Expand Down Expand Up @@ -819,7 +821,7 @@ def _render_learning_new_record_section(
st.rerun(scope="fragment")

learning_date = st.date_input(t("日付"), value=journal_date_value, key="today_learning_date")
learning_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_learning_time")
learning_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_learning_time")
learning_location = st.selectbox(
t("場所"),
options=[None, *LearningLocation],
Expand Down Expand Up @@ -906,7 +908,7 @@ def _render_learning_new_record_section(
st.title(":material/today: " + t("今日"))

if "today_journal_date" not in st.session_state:
st.session_state["today_journal_date"] = date.today()
st.session_state["today_journal_date"] = jst_today()

journal_date: date = st.session_state["today_journal_date"]
with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center"):
Expand Down Expand Up @@ -1129,7 +1131,7 @@ def _render_learning_new_record_section(
st.markdown(f"**{t('体重')}**")
with st.form("today_weight_form"):
weight_date = st.date_input(t("日付"), value=journal_date, key="today_weight_date")
weight_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_weight_time")
weight_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_weight_time")
weight_kg = st.number_input(t("体重(kg)"), min_value=0.0, step=0.1)
submitted_weight = st.form_submit_button(t("体重記録を保存する"), type="primary")

Expand Down Expand Up @@ -1174,7 +1176,7 @@ def _render_learning_new_record_section(
st.markdown(f"**{t('体調(5軸)')}**")
with st.form("today_health_form"):
health_date = st.date_input(t("日付"), value=journal_date, key="today_health_date")
health_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_health_time")
health_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_health_time")
axes = _condition_axis_sliders(key_prefix="today_health_new", initial=None)
symptoms_text = st.text_input(
t("症状(カンマ区切り、任意)"), placeholder=t("例: 頭痛, 倦怠感")
Expand Down Expand Up @@ -1305,7 +1307,7 @@ def _render_learning_new_record_section(

with st.form("today_meal_form"):
meal_date = st.date_input(t("日付"), value=journal_date, key="today_meal_date")
meal_time = st.time_input(t("時刻"), value=datetime.now().time(), key="today_meal_time")
meal_time = st.time_input(t("時刻"), value=jst_now_time(), key="today_meal_time")
meal_type = st.selectbox(
t("食事区分"),
options=list(MealType),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""add unique index on lower(ingredients.name)

食材マスタのget-or-createが同時リクエストで競合(TOCTOU)し、同名の食材が
重複作成されるのを防ぐため、`lower(name)`に一意インデックスを張る
(`IngredientRepository.get_by_name`が大文字小文字を区別しないのに合わせる)。

インデックス作成前に既存の重複を統合する。同じ`lower(name)`の中でidが
最小の行を残し、recipe_ingredientsの参照を付け替えてから重複行を削除する。
付け替えによって同一レシピ内で主キー(recipe_id, ingredient_id)が衝突する
行は、1件だけ残して削除する(同一レシピ内の同名食材は保存時に統合される
ため、通常は発生しない)。

Revision ID: 0010
Revises: 0009
Create Date: (食材マスタの重複作成対策に伴い追加)
"""

from __future__ import annotations

from collections.abc import Sequence

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "0010"
down_revision: str | None = "0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.execute(
"""
CREATE TEMP TABLE ingredient_dedup AS
SELECT id AS dup_id, keep_id
FROM (
SELECT id, first_value(id) OVER (PARTITION BY lower(name) ORDER BY id) AS keep_id
FROM ingredients
) ranked
WHERE id <> keep_id
"""
)
# 付け替え後に同じ(recipe_id, keep_id)になる行のうち、ingredient_idが
# 最小の行(=残す食材を直接参照している行があればそれ)以外を削除する
op.execute(
"""
DELETE FROM recipe_ingredients ri
USING ingredient_dedup d
WHERE ri.ingredient_id = d.dup_id
AND EXISTS (
SELECT 1
FROM recipe_ingredients other
LEFT JOIN ingredient_dedup od ON od.dup_id = other.ingredient_id
WHERE other.recipe_id = ri.recipe_id
AND COALESCE(od.keep_id, other.ingredient_id) = d.keep_id
AND other.ingredient_id < ri.ingredient_id
)
"""
)
op.execute(
"""
UPDATE recipe_ingredients ri
SET ingredient_id = d.keep_id
FROM ingredient_dedup d
WHERE ri.ingredient_id = d.dup_id
"""
)
op.execute("DELETE FROM ingredients WHERE id IN (SELECT dup_id FROM ingredient_dedup)")
op.execute("DROP TABLE ingredient_dedup")

op.execute("CREATE UNIQUE INDEX uq_ingredients_name_lower ON ingredients (lower(name))")


def downgrade() -> None:
# 統合済みの重複データは復元しない(インデックスのみ削除する)
op.drop_index("uq_ingredients_name_lower", table_name="ingredients")
8 changes: 7 additions & 1 deletion src/health_intelligence/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import uuid
from datetime import date, datetime, time

from sqlalchemy import DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, Index, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

Expand Down Expand Up @@ -158,6 +158,12 @@ class IngredientORM(Base):
carbohydrate_per_100g: Mapped[float | None]


# `IngredientRepository.get_by_name`は大文字小文字を区別せずに突合するため、
# 一意制約も`lower(name)`に張る。同時リクエストによるget-or-createの
# 競合(TOCTOU)で同名の食材が重複作成されるのをDB側で防ぐ。
Index("uq_ingredients_name_lower", func.lower(IngredientORM.name), unique=True)


class RecipeORM(Base):
__tablename__ = "recipes"

Expand Down
12 changes: 7 additions & 5 deletions src/health_intelligence/features/daily_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from datetime import date

from health_intelligence.domain.models import MealLog, SleepLog, WeightLog, WorkoutLog
from health_intelligence.local_time import local_date_of


@dataclass(frozen=True, kw_only=True, slots=True)
Expand Down Expand Up @@ -39,17 +40,18 @@ def compute_daily_features(

各引数は呼び出し側であらかじめ対象日でフィルタ済みのものを渡しても、
フィルタ前の全期間分を渡してもよい(この関数内で`target_date`との
突合を行う)。
突合を行う)。`target_date`は日本時間の日付として扱い、各ログの
UTC日時もJSTの日付へ変換してから突合する(local_time.py参照)。

workout_duration_minutesは有酸素運動(CARDIO)のduration_minutesの
合計のみを対象とする。筋力トレーニング(STRENGTH)は所要時間を
記録していないため計上されない(workout_features.pyの
total_strength_volume_kgで別途評価する)。
"""
day_meals = [m for m in meal_logs if m.consumed_at.date() == target_date]
day_sleeps = [s for s in sleep_logs if s.wake_time.date() == target_date]
day_workouts = [w for w in workout_logs if w.performed_at.date() == target_date]
day_weights = [w for w in weight_logs if w.recorded_at.date() == target_date]
day_meals = [m for m in meal_logs if local_date_of(m.consumed_at) == target_date]
day_sleeps = [s for s in sleep_logs if local_date_of(s.wake_time) == target_date]
day_workouts = [w for w in workout_logs if local_date_of(w.performed_at) == target_date]
day_weights = [w for w in weight_logs if local_date_of(w.recorded_at) == target_date]

calories = sum(m.calories for m in day_meals) if day_meals else None
protein_g = sum(m.protein_g for m in day_meals) if day_meals else None
Expand Down
28 changes: 28 additions & 0 deletions src/health_intelligence/local_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""ユーザーの生活時間帯(日本時間)に関するヘルパー。

DomainModel/DBの日時はタイムゾーン付きUTCで統一している
(database/schema.pyのDateTime(timezone=True)参照)。一方で「何日の
記録か」はユーザーの生活時間帯(JST)で区切る必要があり、UTCのまま
`.date()`を取るとJST 0:00〜8:59の記録(朝食・起床・朝の体重測定等)が
前日扱いになる。日次集計・「今日」の判定はこのモジュールを経由する。

実行環境(Streamlit Community Cloud等)のローカルタイムゾーンはUTCの
ため、`date.today()`/`datetime.now()`(naive)は使わないこと。
"""

from __future__ import annotations

from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo

APP_TIMEZONE = ZoneInfo("Asia/Tokyo")


def local_date_of(dt: datetime) -> date:
"""aware datetimeを日本時間での日付に変換する。"""
return dt.astimezone(APP_TIMEZONE).date()


def local_today() -> date:
"""日本時間での今日の日付を返す(実行環境のタイムゾーンに依存しない)。"""
return local_date_of(datetime.now(UTC))
9 changes: 5 additions & 4 deletions src/health_intelligence/ml/real_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
DailyFeatures,
compute_daily_features,
)
from health_intelligence.local_time import local_date_of
from health_intelligence.ml.dataset import MIN_DAYS
from health_intelligence.repositories.sqlalchemy.meal_log_repository import (
SqlAlchemyMealLogRepository,
Expand Down Expand Up @@ -64,10 +65,10 @@ def load_real_daily_features(session: Session) -> list[DailyFeatures]:
weight_logs = weight_repo.list_by_user(target_user_id)

all_dates: set[date] = set()
all_dates.update(m.consumed_at.date() for m in meal_logs)
all_dates.update(s.wake_time.date() for s in sleep_logs)
all_dates.update(w.performed_at.date() for w in workout_logs)
all_dates.update(w.recorded_at.date() for w in weight_logs)
all_dates.update(local_date_of(m.consumed_at) for m in meal_logs)
all_dates.update(local_date_of(s.wake_time) for s in sleep_logs)
all_dates.update(local_date_of(w.performed_at) for w in workout_logs)
all_dates.update(local_date_of(w.recorded_at) for w in weight_logs)
if not all_dates:
return []

Expand Down
8 changes: 7 additions & 1 deletion src/health_intelligence/repositories/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,13 @@ def get_by_name(self, name: str) -> Ingredient | None: ...

def list_all(self) -> list[Ingredient]: ...

def add(self, ingredient: Ingredient) -> None: ...
def add(self, ingredient: Ingredient) -> None:
"""同名(大文字小文字を区別しない)が既に存在する場合は`DuplicateKeyError`。

違反時もトランザクション全体は中断せず、同じUnitOfWork内で
`get_by_name`による引き直しができること。
"""
...


class RecipeRepository(Protocol):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from health_intelligence.database.schema import IngredientORM
from health_intelligence.domain.models import Ingredient
from health_intelligence.repositories.errors import DuplicateKeyError


def _to_domain(orm: IngredientORM) -> Ingredient:
Expand Down Expand Up @@ -55,7 +57,8 @@ def list_by_ids(self, ingredient_ids: list[UUID]) -> list[Ingredient]:

def get_by_name(self, name: str) -> Ingredient | None:
"""大文字小文字を区別せず名前で検索する(get or create用)。"""
stmt = select(IngredientORM).where(func.lower(IngredientORM.name) == name.lower())
# 一意インデックス(lower(name))と判定を揃えるため、比較対象もDB側でlowerする
stmt = select(IngredientORM).where(func.lower(IngredientORM.name) == func.lower(name))
orm = self._session.scalars(stmt).first()
return _to_domain(orm) if orm is not None else None

Expand All @@ -64,4 +67,19 @@ def list_all(self) -> list[Ingredient]:
return [_to_domain(orm) for orm in self._session.scalars(stmt)]

def add(self, ingredient: Ingredient) -> None:
self._session.add(_to_orm(ingredient))
"""Ingredientを追加する。

同名(大文字小文字を区別しない)の食材が既に存在する場合は
`DuplicateKeyError`を送出する。

レシピ生成ではIngredientのget-or-createとRecipeの保存を1つの
トランザクションで行うため、SAVEPOINT(`begin_nested`)内で
`flush()`して一意制約違反を検知する。違反時はSAVEPOINTまでしか
rollbackされないので、呼び出し側は同じUnitOfWorkのまま
`get_by_name`で引き直して処理を続行できる。
"""
try:
with self._session.begin_nested():
self._session.add(_to_orm(ingredient))
except IntegrityError as e:
raise DuplicateKeyError(f"ingredient already exists: {ingredient.name}") from e
Loading
Loading