Changes between Version 5 and Version 6 of AdvancedDatabaseDevelopment


Ignore:
Timestamp:
07/02/26 21:33:02 (4 days ago)
Author:
233062
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • AdvancedDatabaseDevelopment

    v5 v6  
    1 = Напредни извештаи од базата (SQL, складирани процедури и релациона алгебра)
    2 
    3 === 1. Детален годишен извештај за финансиска резилиентност, стабилност на приходи и буџетски притисок по корисник
    4 
    5 ==== SQL
    6 {{{
    7 SET search_path TO trekr;
    8 
    9 WITH months AS (
    10     SELECT generate_series(1, 12) AS month_no
    11 ),
    12 finance_base AS (
    13     SELECT
    14         fu.user_id,
    15         u.username,
    16         u.email,
    17         COALESCE(fu.spending_budget, 0) AS spending_budget,
    18         COALESCE(fu.saving_budget, 0) AS saving_budget,
    19         COALESCE(fu.investing_budget, 0) AS investing_budget,
    20         COALESCE(fu.donation_budget, 0) AS donation_budget,
    21         COALESCE(fu.credit, 0) AS credit
    22     FROM finance_users fu
    23     JOIN users u ON u.user_id = fu.user_id
    24 ),
    25 monthly_income AS (
    26     SELECT
    27         fb.user_id,
    28         m.month_no,
    29         COALESCE(SUM(i.amount), 0) AS month_income
    30     FROM finance_base fb
    31     CROSS JOIN months m
    32     LEFT JOIN incomes i
    33         ON i.user_id = fb.user_id
    34        AND i.date >= DATE '2026-01-01'
    35        AND i.date <  DATE '2027-01-01'
    36        AND EXTRACT(MONTH FROM i.date)::int = m.month_no
    37     GROUP BY fb.user_id, m.month_no
    38 ),
    39 monthly_income_ranked AS (
    40     SELECT
    41         mi.*,
    42         DENSE_RANK() OVER (PARTITION BY mi.user_id ORDER BY mi.month_income DESC, mi.month_no ASC) AS best_month_rank,
    43         DENSE_RANK() OVER (PARTITION BY mi.user_id ORDER BY mi.month_income ASC, mi.month_no ASC) AS worst_month_rank
    44     FROM monthly_income mi
    45 ),
    46 annual_income AS (
    47     SELECT
    48         user_id,
    49         SUM(month_income) AS total_income,
    50         AVG(month_income) AS avg_monthly_income,
    51         STDDEV_SAMP(month_income) AS income_stddev,
    52         MAX(month_income) AS best_month_income,
    53         MIN(month_income) AS worst_month_income,
    54         COUNT(*) FILTER (WHERE month_income > 0) AS active_income_months
    55     FROM monthly_income
    56     GROUP BY user_id
    57 ),
    58 best_worst_months AS (
    59     SELECT
    60         user_id,
    61         MAX(month_no) FILTER (WHERE best_month_rank = 1) AS best_month_no,
    62         MAX(month_no) FILTER (WHERE worst_month_rank = 1) AS worst_month_no
    63     FROM monthly_income_ranked
    64     GROUP BY user_id
     1= Напреден развој на базата =
     2
     3== Валидација на финансиски проценти (Finance Percentage Validation) ==
     4
     5=== Опис на барањата за податочни ограничувања ===
     6
     7Системот мора да обезбеди дека:
     8* Сите пет вредности за буџет мора да бидат внесени (не смеат да бидат NULL)
     9* Секоја вредност мора да биде во опсегот [0, 100]
     10* Збирот на сите пет вредности мора да биде 100 (со толеранција од 0.01 за мали разлики при заокружување)
     11* Ограничувањето се применува и при INSERT и при UPDATE на табелата finance_users
     12
     13=== Имплементација ===
     14
     15==== Тригери ====
     16
     17BEFORE INSERT OR UPDATE тригер на finance_users за валидација дека сите пет буџетски проценти се валидни и нивниот збир е 100.
     18{{{
     19CREATE OR REPLACE FUNCTION trekr.fn_validate_finance_percentages()
     20RETURNS trigger
     21LANGUAGE plpgsql
     22AS $$
     23DECLARE
     24    s   NUMERIC;
     25    eps CONSTANT NUMERIC := 0.01;
     26    vals NUMERIC[] := ARRAY[NEW.spending_budget, NEW.saving_budget,
     27                            NEW.investing_budget, NEW.donation_budget, NEW.credit];
     28    v NUMERIC;
     29BEGIN
     30    FOREACH v IN ARRAY vals LOOP
     31        IF v IS NULL THEN
     32            RAISE EXCEPTION 'All 5 finance percentage values are required';
     33        END IF;
     34        IF v < 0 OR v > 100 THEN
     35            RAISE EXCEPTION 'Finance percentage values must be between 0 and 100';
     36        END IF;
     37    END LOOP;
     38
     39    s := (NEW.spending_budget + NEW.saving_budget + NEW.investing_budget
     40          + NEW.donation_budget + NEW.credit)::numeric;
     41    IF abs(s - 100) > eps THEN
     42        RAISE EXCEPTION 'Finance percentages must sum to 100 (got: %)', s;
     43    END IF;
     44
     45    RETURN NEW;
     46END;
     47$$;
     48
     49DROP TRIGGER IF EXISTS trg_validate_finance_percentages ON trekr.finance_users;
     50CREATE TRIGGER trg_validate_finance_percentages
     51    BEFORE INSERT OR UPDATE ON trekr.finance_users
     52    FOR EACH ROW
     53    EXECUTE FUNCTION trekr.fn_validate_finance_percentages();
     54}}}
     55
     56----
     57
     58== Пресметување на дневни завршувања (Daily Completion Computation) ==
     59
     60=== Опис на барањата за податочни ограничувања ===
     61
     62Системот мора да обезбеди дека:
     63* Дневното завршување може да се пресмета само за корисник со овозможено следење (discipline_users)
     64* Не смее да се пресметува за иден датум
     65* Ако веќе постои запис за тој корисник и датум, се враќа постоечкиот резултат без дупликат
     66* По пресметувањето, завршените задачи се врзуваат за дневниот запис, а потоа нивниот статус се ресетира
     67
     68=== Имплементација ===
     69
     70==== Функции / Stored Procedures ====
     71
     72Функција за пресметување на дневно завршување за еден корисник и датум. Вметнува ред во daily_completion (доколку не постои), ги поврзува завршените задачи и го ресетира нивниот статус. Параметрите се именувани со префикс `p_` за да се избегне двосмисленост со имињата на колоните во plpgsql.
     73{{{
     74CREATE OR REPLACE FUNCTION trekr.fn_compute_daily_completion(
     75    p_user_id bigint,
     76    p_day     date
    6577)
     78RETURNS TABLE(created boolean, out_daily_completion_id bigint, procent numeric)
     79LANGUAGE plpgsql
     80AS $$
     81DECLARE
     82    total_count    bigint;
     83    finished_count bigint;
     84    pct   numeric;
     85    dc_id bigint;
     86    t_row RECORD;
     87BEGIN
     88    IF p_user_id IS NULL THEN
     89        RAISE EXCEPTION 'user_id is required';
     90    END IF;
     91    IF p_day IS NULL THEN
     92        RAISE EXCEPTION 'day is required';
     93    END IF;
     94    IF p_day > current_date THEN
     95        RAISE EXCEPTION 'date cannot be in the future';
     96    END IF;
     97
     98    IF NOT EXISTS (SELECT 1 FROM trekr.discipline_users du WHERE du.user_id = p_user_id) THEN
     99        RAISE EXCEPTION 'Discipline tracking is not enabled for this user';
     100    END IF;
     101
     102    -- доколку веќе постои запис за овој корисник и датум, врати го постоечкиот (идемпотентно)
     103    SELECT dc.daily_completion_id, dc.procent
     104      INTO dc_id, pct
     105    FROM trekr.daily_completion dc
     106    WHERE dc.user_id = p_user_id AND dc.date = p_day
     107    LIMIT 1;
     108
     109    IF dc_id IS NOT NULL THEN
     110        RETURN QUERY SELECT false, dc_id, pct;
     111        RETURN;
     112    END IF;
     113
     114    SELECT COUNT(*) INTO total_count
     115    FROM trekr.tasks t
     116    WHERE t.discipline_user_id = p_user_id;
     117
     118    SELECT COUNT(*) INTO finished_count
     119    FROM trekr.tasks t
     120    WHERE t.discipline_user_id = p_user_id
     121      AND t.is_finished = true;
     122
     123    IF total_count <= 0 THEN
     124        pct := 0;
     125    ELSE
     126        pct := round((finished_count::numeric * 100) / total_count::numeric, 2);
     127    END IF;
     128
     129    INSERT INTO trekr.daily_completion (user_id, date, procent)
     130    VALUES (p_user_id, p_day, pct)
     131    RETURNING daily_completion_id INTO dc_id;
     132
     133    -- врзи ги завршените задачи за дневниот запис
     134    FOR t_row IN
     135        SELECT t.task_id FROM trekr.tasks t
     136        WHERE t.discipline_user_id = p_user_id
     137          AND t.is_finished = true
     138    LOOP
     139        INSERT INTO trekr.task_daily_completion (task_id, daily_completion_id)
     140        VALUES (t_row.task_id, dc_id)
     141        ON CONFLICT DO NOTHING;
     142    END LOOP;
     143
     144    -- ресетирај го статусот на завршените задачи
     145    UPDATE trekr.tasks t SET is_finished = false
     146    WHERE t.discipline_user_id = p_user_id;
     147
     148    RETURN QUERY SELECT true, dc_id, pct;
     149END;
     150$$;
     151}}}
     152
     153Функција за пресметување на дневни завршувања за сите корисници со овозможено следење за даден датум. Грешките по корисник се логираат и се продолжува понатаму.
     154{{{
     155CREATE OR REPLACE FUNCTION trekr.fn_compute_daily_completion_for_all(p_day date)
     156RETURNS void
     157LANGUAGE plpgsql
     158AS $$
     159DECLARE
     160    u RECORD;
     161BEGIN
     162    IF p_day IS NULL THEN
     163        RAISE EXCEPTION 'day is required';
     164    END IF;
     165
     166    FOR u IN SELECT user_id FROM trekr.discipline_users LOOP
     167        BEGIN
     168            PERFORM trekr.fn_compute_daily_completion(u.user_id, p_day);
     169        EXCEPTION WHEN OTHERS THEN
     170            RAISE NOTICE 'compute_daily_completion failed for user %: %', u.user_id, SQLERRM;
     171        END;
     172    END LOOP;
     173END;
     174$$;
     175
     176-- Опционално: pg_cron задача за секојдневно извршување (бара pg_cron екстензија)
     177-- CREATE EXTENSION IF NOT EXISTS pg_cron;
     178-- SELECT cron.schedule('compute_daily_completions_every_day', '59 23 * * *',
     179--     $$SELECT trekr.fn_compute_daily_completion_for_all(current_date - INTERVAL '1 day')$$);
     180}}}
     181
     182----
     183
     184== Дополнителни ограничувања на базата (Additional DB Constraints) ==
     185
     186=== Опис на барањата за податочни ограничувања ===
     187
     188Системот мора да обезбеди дека:
     189* Корисникот може да има само еден дневен внес (daily intake) по датум
     190* Тренинг сесиите не смеат да имаат иден датум
     191
     192=== Имплементација ===
     193
     194==== Индекси ====
     195
     196Уникатен индекс на daily_intakes за осигурување дека еден корисник може да има најмногу еден внес по датум. (Колоната во daily_intakes е `user_id` — надворешен клуч кон weight_users.)
     197{{{
     198-- Напомена: доколку веќе има дупликати (user_id, date) во податоците,
     199-- прво отстранете ги, инаку креирањето на уникатниот индекс ќе падне:
     200--   DELETE FROM trekr.daily_intakes a USING trekr.daily_intakes b
     201--   WHERE a.ctid < b.ctid AND a.user_id = b.user_id AND a.date = b.date;
     202
     203DO $$
     204BEGIN
     205    IF NOT EXISTS (
     206        SELECT 1 FROM pg_indexes
     207        WHERE schemaname = 'trekr'
     208          AND tablename  = 'daily_intakes'
     209          AND indexname  = 'uq_daily_intake_user_date'
     210    ) THEN
     211        CREATE UNIQUE INDEX uq_daily_intake_user_date
     212        ON trekr.daily_intakes (user_id, date);
     213    END IF;
     214END$$;
     215}}}
     216
     217==== Тригери ====
     218
     219BEFORE INSERT OR UPDATE тригер на training_sessions за спречување на внес со иден датум.
     220{{{
     221CREATE OR REPLACE FUNCTION trekr.fn_check_training_date()
     222RETURNS trigger
     223LANGUAGE plpgsql
     224AS $$
     225BEGIN
     226    IF NEW.date > current_date THEN
     227        RAISE EXCEPTION 'Training session date cannot be in the future: %', NEW.date;
     228    END IF;
     229    RETURN NEW;
     230END;
     231$$;
     232
     233DROP TRIGGER IF EXISTS trg_check_training_date ON trekr.training_sessions;
     234CREATE TRIGGER trg_check_training_date
     235    BEFORE INSERT OR UPDATE ON trekr.training_sessions
     236    FOR EACH ROW
     237    EXECUTE FUNCTION trekr.fn_check_training_date();
     238}}}
     239
     240----
     241
     242== Прегледи за финансии (Finance Views) ==
     243
     244=== Опис на барањата за податочни ограничувања ===
     245
     246Системот мора да обезбеди дека:
     247* Постои преглед за месечен приход по корисник и период
     248* Постои преглед за вкупниот приход на корисникот во тековниот месец
     249* Постои преглед за пресметани апсолутни износи по категорија врз основа на процентите и приходот во тековниот месец
     250
     251=== Имплементација ===
     252
     253==== Погледи (Views) ====
     254
     255Поглед за месечен приход по корисник — прикажува вкупен приход по корисник, месец и година.
     256{{{
     257CREATE OR REPLACE VIEW trekr.vw_finance_monthly_summary AS
    66258SELECT
    67     fb.user_id,
    68     fb.username,
    69     fb.email,
    70     (fb.spending_budget + fb.saving_budget + fb.investing_budget + fb.donation_budget) * 12 AS planned_annual_budget,
    71     ai.total_income AS actual_annual_income,
    72     ai.avg_monthly_income,
    73     ai.active_income_months,
    74     ai.best_month_income,
    75     ai.worst_month_income,
    76     bwm.best_month_no,
    77     bwm.worst_month_no,
    78     ROUND(
    79         (ai.income_stddev / NULLIF(ai.avg_monthly_income, 0))::numeric,
    80         4
    81     ) AS income_volatility_cv,
    82     ROUND(
    83         (ai.total_income - (fb.spending_budget * 12))::numeric,
    84         2
    85     ) AS annual_free_cash_after_spending,
    86     ROUND(
    87         ((fb.spending_budget * 12) / NULLIF(ai.total_income, 0))::numeric,
    88         4
    89     ) AS spending_pressure_ratio,
    90     ROUND(
    91         (fb.credit / NULLIF(ai.total_income, 0))::numeric,
    92         4
    93     ) AS leverage_ratio,
    94     DENSE_RANK() OVER (
    95         ORDER BY
    96             (ai.total_income - (fb.spending_budget * 12)) DESC,
    97             ((fb.spending_budget * 12) / NULLIF(ai.total_income, 0)) ASC,
    98             fb.user_id ASC
    99     ) AS finance_resilience_rank
    100 FROM finance_base fb
    101 JOIN annual_income ai ON ai.user_id = fb.user_id
    102 JOIN best_worst_months bwm ON bwm.user_id = fb.user_id
    103 ORDER BY finance_resilience_rank, fb.user_id;
    104 }}}
    105 
    106 ==== Релациона Алгебра
    107 {{{
    108 FB <- pi_{fu.user_id, u.username, u.email,
    109           COALESCE(fu.spending_budget,0)->spending_budget,
    110           COALESCE(fu.saving_budget,0)->saving_budget,
    111           COALESCE(fu.investing_budget,0)->investing_budget,
    112           COALESCE(fu.donation_budget,0)->donation_budget,
    113           COALESCE(fu.credit,0)->credit}
    114       (finance_users fu bowtie_{fu.user_id = u.user_id} users u)
    115 
    116 FBM <- FB x M
    117 IY <- sigma_{i.date >= '2026-01-01' AND i.date < '2027-01-01'}(incomes i)
    118 MI0 <- FBM leftouterjoin_{FBM.user_id = i.user_id AND FBM.month_no = MONTH(i.date)} IY
    119 MI <- gamma_{user_id, month_no;
    120              SUM(COALESCE(i.amount,0))->month_income}(MI0)
    121 
    122 MIR <- omega_{PARTITION BY user_id ORDER BY month_income DESC, month_no ASC;
    123               DENSE_RANK()->best_month_rank,
    124               DENSE_RANK(PARTITION BY user_id ORDER BY month_income ASC, month_no ASC)->worst_month_rank}(MI)
    125 
    126 AI <- gamma_{user_id;
    127              SUM(month_income)->total_income,
    128              AVG(month_income)->avg_monthly_income,
    129              STDDEV_SAMP(month_income)->income_stddev,
    130              MAX(month_income)->best_month_income,
    131              MIN(month_income)->worst_month_income,
    132              COUNT_IF(month_income>0)->active_income_months}(MI)
    133 
    134 BWM <- gamma_{user_id;
    135               MAX_IF(month_no, best_month_rank=1)->best_month_no,
    136               MAX_IF(month_no, worst_month_rank=1)->worst_month_no}(MIR)
    137 
    138 R0 <- FB bowtie_{FB.user_id=AI.user_id} AI bowtie_{FB.user_id=BWM.user_id} BWM
    139 R1 <- alpha_{(spending_budget+saving_budget+investing_budget+donation_budget)*12->planned_annual_budget,
    140              total_income->actual_annual_income,
    141              income_stddev/NULLIF(avg_monthly_income,0)->income_volatility_cv,
    142              total_income-(spending_budget*12)->annual_free_cash_after_spending,
    143              (spending_budget*12)/NULLIF(total_income,0)->spending_pressure_ratio,
    144              credit/NULLIF(total_income,0)->leverage_ratio}(R0)
    145 R  <- omega_{ORDER BY annual_free_cash_after_spending DESC,
    146                     spending_pressure_ratio ASC,
    147                     user_id ASC;
    148              DENSE_RANK()->finance_resilience_rank}(R1)
    149 }}}
    150 
    151 === 2. Детален годишен извештај за конзистентност на тренинг, оптоварување и тренд на перформанс
    152 
    153 ==== SQL
    154 {{{
    155 SET search_path TO trekr;
    156 
    157 WITH months AS (
    158     SELECT generate_series(1, 12) AS month_no
    159 ),
    160 training_base AS (
    161     SELECT
    162         tu.user_id,
    163         u.username,
    164         u.email,
    165         tu.gender,
    166         tu.age,
    167         tu.weight
    168     FROM training_users tu
    169     JOIN users u ON u.user_id = tu.user_id
    170 ),
    171 monthly_sessions AS (
    172     SELECT
    173         tb.user_id,
    174         m.month_no,
    175         COALESCE(COUNT(ts.training_id), 0) AS sessions_count,
    176         COALESCE(SUM(ts.duration), 0) AS total_duration_minutes,
    177         COALESCE(SUM(ts.calories), 0) AS total_calories,
    178         COALESCE(AVG(ts.duration), 0) AS avg_session_duration,
    179         COALESCE(AVG(ts.calories), 0) AS avg_session_calories
    180     FROM training_base tb
    181     CROSS JOIN months m
    182     LEFT JOIN training_sessions ts
    183         ON ts.training_user_id = tb.user_id
    184        AND ts.date >= DATE '2026-01-01'
    185        AND ts.date <  DATE '2027-01-01'
    186        AND EXTRACT(MONTH FROM ts.date)::int = m.month_no
    187     GROUP BY tb.user_id, m.month_no
    188 ),
    189 monthly_ranked AS (
    190     SELECT
    191         ms.*,
    192         DENSE_RANK() OVER (PARTITION BY ms.user_id ORDER BY ms.total_calories DESC, ms.month_no ASC) AS peak_calorie_month_rank,
    193         DENSE_RANK() OVER (PARTITION BY ms.user_id ORDER BY ms.sessions_count DESC, ms.month_no ASC) AS peak_sessions_month_rank
    194     FROM monthly_sessions ms
    195 ),
    196 active_month_streaks AS (
    197     SELECT
    198         user_id,
    199         month_no,
    200         month_no - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY month_no) AS grp
    201     FROM monthly_sessions
    202     WHERE sessions_count > 0
    203 ),
    204 longest_streak AS (
    205     SELECT
    206         user_id,
    207         MAX(streak_len) AS longest_active_month_streak
    208     FROM (
    209         SELECT user_id, grp, COUNT(*) AS streak_len
    210         FROM active_month_streaks
    211         GROUP BY user_id, grp
    212     ) s
    213     GROUP BY user_id
    214 ),
    215 annual_training AS (
    216     SELECT
    217         user_id,
    218         SUM(sessions_count) AS annual_sessions,
    219         SUM(total_duration_minutes) AS annual_duration_minutes,
    220         SUM(total_calories) AS annual_calories,
    221         AVG(total_duration_minutes) AS avg_monthly_duration,
    222         AVG(total_calories) AS avg_monthly_calories,
    223         COUNT(*) FILTER (WHERE sessions_count > 0) AS active_months,
    224         REGR_SLOPE(total_calories::numeric, month_no::numeric) AS calories_trend_slope,
    225         REGR_SLOPE(total_duration_minutes::numeric, month_no::numeric) AS duration_trend_slope
    226     FROM monthly_sessions
    227     GROUP BY user_id
    228 ),
    229 peak_months AS (
    230     SELECT
    231         user_id,
    232         MAX(month_no) FILTER (WHERE peak_calorie_month_rank = 1) AS peak_calorie_month_no,
    233         MAX(month_no) FILTER (WHERE peak_sessions_month_rank = 1) AS peak_sessions_month_no
    234     FROM monthly_ranked
    235     GROUP BY user_id
    236 )
     259    i.user_id,
     260    EXTRACT(YEAR  FROM i.date)::int AS year,
     261    EXTRACT(MONTH FROM i.date)::int AS month,
     262    SUM(i.amount) AS total_income
     263FROM trekr.incomes i
     264GROUP BY i.user_id, EXTRACT(YEAR FROM i.date), EXTRACT(MONTH FROM i.date);
     265}}}
     266
     267Поглед за вкупниот приход на секој корисник во тековниот месец.
     268{{{
     269CREATE OR REPLACE VIEW trekr.vw_finance_current_month AS
    237270SELECT
    238     tb.user_id,
    239     tb.username,
    240     tb.email,
    241     tb.gender,
    242     tb.age,
    243     tb.weight,
    244     at.annual_sessions,
    245     ROUND(at.annual_duration_minutes::numeric, 2) AS annual_duration_minutes,
    246     ROUND(at.annual_calories::numeric, 2) AS annual_calories,
    247     at.active_months,
    248     ROUND((at.active_months / 12.0)::numeric, 4) AS consistency_ratio,
    249     COALESCE(ls.longest_active_month_streak, 0) AS longest_active_month_streak,
    250     pm.peak_calorie_month_no,
    251     pm.peak_sessions_month_no,
    252     ROUND(COALESCE(at.calories_trend_slope, 0)::numeric, 4) AS calories_trend_slope,
    253     ROUND(COALESCE(at.duration_trend_slope, 0)::numeric, 4) AS duration_trend_slope,
    254     DENSE_RANK() OVER (
    255         ORDER BY
    256             at.annual_calories DESC,
    257             at.active_months DESC,
    258             COALESCE(ls.longest_active_month_streak, 0) DESC,
    259             tb.user_id ASC
    260     ) AS training_annual_rank
    261 FROM training_base tb
    262 JOIN annual_training at ON at.user_id = tb.user_id
    263 JOIN peak_months pm ON pm.user_id = tb.user_id
    264 LEFT JOIN longest_streak ls ON ls.user_id = tb.user_id
    265 ORDER BY training_annual_rank, tb.user_id;
    266 }}}
    267 
    268 ==== Релациона Алгебра
    269 {{{
    270 TB <- pi_{tu.user_id, u.username, u.email, tu.gender, tu.age, tu.weight}
    271       (training_users tu bowtie_{tu.user_id = u.user_id} users u)
    272 
    273 TBM <- TB x M
    274 TSY <- sigma_{ts.date >= '2026-01-01' AND ts.date < '2027-01-01'}(training_sessions ts)
    275 MS0 <- TBM leftouterjoin_{TBM.user_id = ts.training_user_id AND TBM.month_no = MONTH(ts.date)} TSY
    276 MS <- gamma_{user_id, month_no;
    277              COUNT(ts.training_id)->sessions_count,
    278              SUM(COALESCE(ts.duration,0))->total_duration_minutes,
    279              SUM(COALESCE(ts.calories,0))->total_calories,
    280              AVG(COALESCE(ts.duration,0))->avg_session_duration,
    281              AVG(COALESCE(ts.calories,0))->avg_session_calories}(MS0)
    282 
    283 MR <- omega_{PARTITION BY user_id ORDER BY total_calories DESC, month_no ASC;
    284              DENSE_RANK()->peak_calorie_month_rank,
    285              DENSE_RANK(PARTITION BY user_id ORDER BY sessions_count DESC, month_no ASC)->peak_sessions_month_rank}(MS)
    286 
    287 AMS <- sigma_{sessions_count>0}(MS)
    288 AMS1 <- omega_{PARTITION BY user_id ORDER BY month_no;
    289                ROW_NUMBER()->rn}(AMS)
    290 AMS2 <- alpha_{month_no - rn -> grp}(AMS1)
    291 LS0 <- gamma_{user_id, grp; COUNT(*)->streak_len}(AMS2)
    292 LS  <- gamma_{user_id; MAX(streak_len)->longest_active_month_streak}(LS0)
    293 
    294 AT <- gamma_{user_id;
    295              SUM(sessions_count)->annual_sessions,
    296              SUM(total_duration_minutes)->annual_duration_minutes,
    297              SUM(total_calories)->annual_calories,
    298              AVG(total_duration_minutes)->avg_monthly_duration,
    299              AVG(total_calories)->avg_monthly_calories,
    300              COUNT_IF(sessions_count>0)->active_months,
    301              REGR_SLOPE(total_calories, month_no)->calories_trend_slope,
    302              REGR_SLOPE(total_duration_minutes, month_no)->duration_trend_slope}(MS)
    303 
    304 PM <- gamma_{user_id;
    305              MAX_IF(month_no, peak_calorie_month_rank=1)->peak_calorie_month_no,
    306              MAX_IF(month_no, peak_sessions_month_rank=1)->peak_sessions_month_no}(MR)
    307 
    308 R0 <- TB bowtie_{TB.user_id=AT.user_id} AT
    309          bowtie_{TB.user_id=PM.user_id} PM
    310          leftouterjoin_{TB.user_id=LS.user_id} LS
    311 R1 <- alpha_{active_months/12.0->consistency_ratio,
    312              COALESCE(longest_active_month_streak,0)->longest_active_month_streak_nz,
    313              COALESCE(calories_trend_slope,0)->calories_trend_slope_nz,
    314              COALESCE(duration_trend_slope,0)->duration_trend_slope_nz}(R0)
    315 R  <- omega_{ORDER BY annual_calories DESC,
    316                     active_months DESC,
    317                     longest_active_month_streak_nz DESC,
    318                     user_id ASC;
    319              DENSE_RANK()->training_annual_rank}(R1)
    320 }}}
    321 
    322 === 3. Детален годишен извештај за дисциплина, квалитет на завршување и однесување преку streaks
    323 
    324 ==== SQL
    325 {{{
    326 SET search_path TO trekr;
    327 
    328 WITH discipline_base AS (
    329     SELECT
    330         du.user_id,
    331         u.username,
    332         u.email
    333     FROM discipline_users du
    334     JOIN users u ON u.user_id = du.user_id
    335 ),
    336 task_mix AS (
    337     SELECT
    338         COALESCE(t.discipline_user_id, c.user_id) AS user_id,
    339         COUNT(*) AS total_tasks_defined,
    340         COUNT(*) FILTER (WHERE t.custom_tracking_id IS NULL) AS core_tasks,
    341         COUNT(*) FILTER (WHERE t.custom_tracking_id IS NOT NULL) AS custom_tasks,
    342         COUNT(DISTINCT COALESCE(t.custom_tracking_id::text, 'core')) AS task_category_span
    343     FROM tasks t
    344     LEFT JOIN custom_tracking_categories c
    345         ON c.custom_tracking_id = t.custom_tracking_id
    346     WHERE t.discipline_user_id IS NOT NULL
    347        OR t.custom_tracking_id IS NOT NULL
    348     GROUP BY COALESCE(t.discipline_user_id, c.user_id)
    349 ),
    350 annual_daily_completion AS (
    351     SELECT
    352         dc.user_id,
    353         dc.date,
    354         COALESCE(dc.procent, 0) AS procent,
    355         CASE WHEN COALESCE(dc.procent, 0) >= 80 THEN 1 ELSE 0 END AS strong_day
    356     FROM daily_completion dc
    357     WHERE EXTRACT(YEAR FROM dc.date)::int = 2026
    358 ),
    359 daily_completion_stats AS (
    360     SELECT
    361         adc.user_id,
    362         COUNT(*) AS tracked_days,
    363         AVG(adc.procent) AS avg_completion_percent,
    364         PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY adc.procent) AS median_completion_percent,
    365         COUNT(*) FILTER (WHERE adc.procent = 100) AS perfect_days,
    366         COUNT(*) FILTER (WHERE adc.procent >= 80) AS strong_days,
    367         STDDEV_SAMP(adc.procent) AS completion_variability
    368     FROM annual_daily_completion adc
    369     GROUP BY adc.user_id
    370 ),
    371 strong_day_streaks AS (
    372     SELECT
    373         user_id,
    374         date,
    375         date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY date))::int AS grp
    376     FROM annual_daily_completion
    377     WHERE strong_day = 1
    378 ),
    379 longest_strong_streak AS (
    380     SELECT
    381         user_id,
    382         MAX(streak_len) AS longest_strong_day_streak
    383     FROM (
    384         SELECT user_id, grp, COUNT(*) AS streak_len
    385         FROM strong_day_streaks
    386         GROUP BY user_id, grp
    387     ) s
    388     GROUP BY user_id
    389 ),
    390 annual_task_execution AS (
    391     SELECT
    392         dc.user_id,
    393         COUNT(tdc.task_id) AS completed_task_events
    394     FROM daily_completion dc
    395     LEFT JOIN task_daily_completion tdc
    396         ON tdc.daily_completion_id = dc.daily_completion_id
    397     WHERE EXTRACT(YEAR FROM dc.date)::int = 2026
    398     GROUP BY dc.user_id
    399 )
     271    f.user_id,
     272    COALESCE(SUM(i.amount), 0) AS total_earned_this_month
     273FROM trekr.finance_users f
     274LEFT JOIN trekr.incomes i
     275    ON i.user_id = f.user_id
     276   AND date_trunc('month', i.date) = date_trunc('month', current_date)
     277GROUP BY f.user_id;
     278}}}
     279
     280Поглед за пресметување на апсолутни износи по финансиска категорија за тековниот месец, врз основа на буџетските проценти и вкупниот приход.
     281{{{
     282CREATE OR REPLACE VIEW trekr.vw_finance_allocations_current_month AS
    400283SELECT
    401     db.user_id,
    402     db.username,
    403     db.email,
    404     COALESCE(tm.total_tasks_defined, 0) AS total_tasks_defined,
    405     COALESCE(tm.core_tasks, 0) AS core_tasks,
    406     COALESCE(tm.custom_tasks, 0) AS custom_tasks,
    407     COALESCE(tm.task_category_span, 0) AS task_category_span,
    408     COALESCE(dcs.tracked_days, 0) AS tracked_days,
    409     ROUND(COALESCE(dcs.avg_completion_percent, 0)::numeric, 2) AS avg_completion_percent,
    410     ROUND(COALESCE(dcs.median_completion_percent, 0)::numeric, 2) AS median_completion_percent,
    411     COALESCE(dcs.perfect_days, 0) AS perfect_days,
    412     COALESCE(dcs.strong_days, 0) AS strong_days,
    413     ROUND(COALESCE(dcs.completion_variability, 0)::numeric, 4) AS completion_variability,
    414     COALESCE(ate.completed_task_events, 0) AS completed_task_events,
    415     COALESCE(lss.longest_strong_day_streak, 0) AS longest_strong_day_streak,
    416     ROUND(
    417         COALESCE((COALESCE(dcs.strong_days, 0) / NULLIF(COALESCE(dcs.tracked_days, 0), 0)::numeric), 0),
    418         4
    419     ) AS strong_day_ratio,
    420     ROUND(
    421         (
    422             COALESCE(dcs.avg_completion_percent, 0) * 0.45
    423             + COALESCE(lss.longest_strong_day_streak, 0) * 2.00
    424             + COALESCE(ate.completed_task_events, 0) * 0.35
    425         )::numeric,
    426         2
    427     ) AS discipline_composite_score,
    428     DENSE_RANK() OVER (
    429         ORDER BY
    430             (
    431                 COALESCE(dcs.avg_completion_percent, 0) * 0.45
    432                 + COALESCE(lss.longest_strong_day_streak, 0) * 2.00
    433                 + COALESCE(ate.completed_task_events, 0) * 0.35
    434             ) DESC,
    435             db.user_id ASC
    436     ) AS discipline_annual_rank
    437 FROM discipline_base db
    438 LEFT JOIN task_mix tm ON tm.user_id = db.user_id
    439 LEFT JOIN daily_completion_stats dcs ON dcs.user_id = db.user_id
    440 LEFT JOIN annual_task_execution ate ON ate.user_id = db.user_id
    441 LEFT JOIN longest_strong_streak lss ON lss.user_id = db.user_id
    442 ORDER BY discipline_annual_rank, db.user_id;
    443 }}}
    444 
    445 ==== Релациона Алгебра
    446 {{{
    447 DB <- pi_{du.user_id, u.username, u.email}
    448       (discipline_users du bowtie_{du.user_id = u.user_id} users u)
    449 
    450 TC <- tasks t leftouterjoin_{t.custom_tracking_id = c.custom_tracking_id} custom_tracking_categories c
    451 TM0 <- alpha_{COALESCE(t.discipline_user_id, c.user_id)->owner_user_id}(TC)
    452 TM1 <- sigma_{t.discipline_user_id IS NOT NULL OR t.custom_tracking_id IS NOT NULL}(TM0)
    453 TM <- gamma_{owner_user_id;
    454              COUNT(*)->total_tasks_defined,
    455              COUNT_IF(t.custom_tracking_id IS NULL)->core_tasks,
    456              COUNT_IF(t.custom_tracking_id IS NOT NULL)->custom_tasks,
    457              COUNT_DISTINCT(COALESCE(t.custom_tracking_id,'core'))->task_category_span}(TM1)
    458 
    459 ADC0 <- sigma_{YEAR(dc.date)=2026}(daily_completion dc)
    460 ADC <- alpha_{COALESCE(dc.procent,0)->procent,
    461               CASE(procent>=80,1,0)->strong_day}(ADC0)
    462 
    463 DCS <- gamma_{user_id;
    464               COUNT(*)->tracked_days,
    465               AVG(procent)->avg_completion_percent,
    466               PERCENTILE_CONT_0_5(procent)->median_completion_percent,
    467               COUNT_IF(procent=100)->perfect_days,
    468               COUNT_IF(procent>=80)->strong_days,
    469               STDDEV_SAMP(procent)->completion_variability}(ADC)
    470 
    471 SDS0 <- sigma_{strong_day=1}(ADC)
    472 SDS1 <- omega_{PARTITION BY user_id ORDER BY date; ROW_NUMBER()->rn}(SDS0)
    473 SDS2 <- alpha_{date - rn -> grp}(SDS1)
    474 LSS0 <- gamma_{user_id, grp; COUNT(*)->streak_len}(SDS2)
    475 LSS  <- gamma_{user_id; MAX(streak_len)->longest_strong_day_streak}(LSS0)
    476 
    477 ATE0 <- ADC0 leftouterjoin_{ADC0.daily_completion_id = tdc.daily_completion_id} task_daily_completion tdc
    478 ATE  <- gamma_{ADC0.user_id; COUNT(tdc.task_id)->completed_task_events}(ATE0)
    479 
    480 R0 <- DB
    481       leftouterjoin_{DB.user_id = TM.owner_user_id} TM
    482       leftouterjoin_{DB.user_id = DCS.user_id} DCS
    483       leftouterjoin_{DB.user_id = ATE.user_id} ATE
    484       leftouterjoin_{DB.user_id = LSS.user_id} LSS
    485 R1 <- alpha_{COALESCE(total_tasks_defined,0)->total_tasks_defined_nz,
    486              COALESCE(core_tasks,0)->core_tasks_nz,
    487              COALESCE(custom_tasks,0)->custom_tasks_nz,
    488              COALESCE(task_category_span,0)->task_category_span_nz,
    489              COALESCE(tracked_days,0)->tracked_days_nz,
    490              COALESCE(avg_completion_percent,0)->avg_completion_percent_nz,
    491              COALESCE(median_completion_percent,0)->median_completion_percent_nz,
    492              COALESCE(perfect_days,0)->perfect_days_nz,
    493              COALESCE(strong_days,0)->strong_days_nz,
    494              COALESCE(completion_variability,0)->completion_variability_nz,
    495              COALESCE(completed_task_events,0)->completed_task_events_nz,
    496              COALESCE(longest_strong_day_streak,0)->longest_strong_day_streak_nz,
    497              COALESCE(strong_days/NULLIF(tracked_days,0),0)->strong_day_ratio,
    498              (COALESCE(avg_completion_percent,0)*0.45 +
    499               COALESCE(longest_strong_day_streak,0)*2.00 +
    500               COALESCE(completed_task_events,0)*0.35)->discipline_composite_score}(R0)
    501 R  <- omega_{ORDER BY discipline_composite_score DESC, user_id ASC;
    502              DENSE_RANK()->discipline_annual_rank}(R1)
    503 }}}
    504 
    505 === 4. Детален годишен извештај за инвестициска диверзификација, концентрација и темпо на вложување
    506 
    507 ==== SQL
    508 {{{
    509 SET search_path TO trekr;
    510 
    511 WITH months AS (
    512     SELECT generate_series(1, 12) AS month_no
    513 ),
    514 investor_base AS (
    515     SELECT
    516         iu.user_id,
    517         u.username,
    518         u.email
    519     FROM investor_users iu
    520     JOIN users u ON u.user_id = iu.user_id
    521 ),
    522 annual_asset_lots AS (
    523     SELECT
    524         a.user_id,
    525         a.ticker_symbol,
    526         COALESCE(a.quantity, 0) AS quantity,
    527         COALESCE(a.buy_price, 0) AS buy_price,
    528         COALESCE(a.quantity, 0) * COALESCE(a.buy_price, 0) AS invested_amount,
    529         a.buy_date
    530     FROM assets a
    531     WHERE a.buy_date >= DATE '2026-01-01'
    532       AND a.buy_date <  DATE '2027-01-01'
    533 ),
    534 ticker_rollup AS (
    535     SELECT
    536         aal.user_id,
    537         aal.ticker_symbol,
    538         SUM(aal.quantity) AS total_quantity,
    539         SUM(aal.invested_amount) AS total_invested_amount,
    540         COUNT(*) AS lot_count,
    541         MIN(aal.buy_date) AS first_buy_date,
    542         MAX(aal.buy_date) AS last_buy_date
    543     FROM annual_asset_lots aal
    544     GROUP BY aal.user_id, aal.ticker_symbol
    545 ),
    546 portfolio_totals AS (
    547     SELECT
    548         user_id,
    549         SUM(total_invested_amount) AS annual_total_invested,
    550         SUM(lot_count) AS annual_lot_count,
    551         COUNT(*) AS distinct_tickers
    552     FROM ticker_rollup
    553     GROUP BY user_id
    554 ),
    555 weights AS (
    556     SELECT
    557         tr.user_id,
    558         tr.ticker_symbol,
    559         tr.total_invested_amount,
    560         pt.annual_total_invested,
    561         (tr.total_invested_amount / NULLIF(pt.annual_total_invested, 0)) AS position_weight,
    562         DENSE_RANK() OVER (
    563             PARTITION BY tr.user_id
    564             ORDER BY tr.total_invested_amount DESC, tr.ticker_symbol ASC
    565         ) AS position_rank
    566     FROM ticker_rollup tr
    567     JOIN portfolio_totals pt ON pt.user_id = tr.user_id
    568 ),
    569 concentration AS (
    570     SELECT
    571         user_id,
    572         SUM(position_weight * position_weight) AS hhi_concentration,
    573         MAX(position_weight) AS top_position_weight,
    574         MAX(ticker_symbol) FILTER (WHERE position_rank = 1) AS top_ticker
    575     FROM weights
    576     GROUP BY user_id
    577 ),
    578 monthly_investment AS (
    579     SELECT
    580         ib.user_id,
    581         m.month_no,
    582         COALESCE(SUM(a.quantity * a.buy_price), 0) AS monthly_invested_amount
    583     FROM investor_base ib
    584     CROSS JOIN months m
    585     LEFT JOIN assets a
    586         ON a.user_id = ib.user_id
    587        AND a.buy_date >= DATE '2026-01-01'
    588        AND a.buy_date <  DATE '2027-01-01'
    589        AND EXTRACT(MONTH FROM a.buy_date)::int = m.month_no
    590     GROUP BY ib.user_id, m.month_no
    591 ),
    592 monthly_investment_stats AS (
    593     SELECT
    594         user_id,
    595         AVG(monthly_invested_amount) AS avg_monthly_contribution,
    596         STDDEV_SAMP(monthly_invested_amount) AS contribution_stddev,
    597         COUNT(*) FILTER (WHERE monthly_invested_amount > 0) AS active_investing_months
    598     FROM monthly_investment
    599     GROUP BY user_id
    600 )
    601 SELECT
    602     ib.user_id,
    603     ib.username,
    604     ib.email,
    605     COALESCE(pt.annual_total_invested, 0) AS annual_total_invested,
    606     COALESCE(pt.annual_lot_count, 0) AS annual_lot_count,
    607     COALESCE(pt.distinct_tickers, 0) AS distinct_tickers,
    608     ROUND(COALESCE(ms.avg_monthly_contribution, 0)::numeric, 2) AS avg_monthly_contribution,
    609     COALESCE(ms.active_investing_months, 0) AS active_investing_months,
    610     ROUND((COALESCE(ms.active_investing_months, 0) / 12.0)::numeric, 4) AS activity_ratio,
    611     ROUND(COALESCE(c.hhi_concentration, 0)::numeric, 4) AS hhi_concentration,
    612     ROUND((1 - COALESCE(c.hhi_concentration, 1))::numeric, 4) AS diversification_index,
    613     ROUND(COALESCE(c.top_position_weight, 0)::numeric, 4) AS top_position_weight,
    614     c.top_ticker,
    615     ROUND((COALESCE(ms.contribution_stddev, 0) / NULLIF(ms.avg_monthly_contribution, 0))::numeric, 4) AS contribution_volatility_cv,
    616     DENSE_RANK() OVER (
    617         ORDER BY
    618             (1 - COALESCE(c.hhi_concentration, 1)) DESC,
    619             COALESCE(pt.annual_total_invested, 0) DESC,
    620             COALESCE(ms.active_investing_months, 0) DESC,
    621             ib.user_id ASC
    622     ) AS investing_annual_rank
    623 FROM investor_base ib
    624 LEFT JOIN portfolio_totals pt ON pt.user_id = ib.user_id
    625 LEFT JOIN concentration c ON c.user_id = ib.user_id
    626 LEFT JOIN monthly_investment_stats ms ON ms.user_id = ib.user_id
    627 ORDER BY investing_annual_rank, ib.user_id;
    628 }}}
    629 
    630 ==== Релациона Алгебра
    631 {{{
    632 IB <- pi_{iu.user_id, u.username, u.email}
    633       (investor_users iu bowtie_{iu.user_id = u.user_id} users u)
    634 
    635 AAL <- pi_{a.user_id, a.ticker_symbol,
    636            COALESCE(a.quantity,0)->quantity,
    637            COALESCE(a.buy_price,0)->buy_price,
    638            COALESCE(a.quantity,0)*COALESCE(a.buy_price,0)->invested_amount,
    639            a.buy_date}
    640        (sigma_{a.buy_date >= '2026-01-01' AND a.buy_date < '2027-01-01'}(assets a))
    641 
    642 TR <- gamma_{user_id, ticker_symbol;
    643              SUM(quantity)->total_quantity,
    644              SUM(invested_amount)->total_invested_amount,
    645              COUNT(*)->lot_count,
    646              MIN(buy_date)->first_buy_date,
    647              MAX(buy_date)->last_buy_date}(AAL)
    648 
    649 PT <- gamma_{user_id;
    650              SUM(total_invested_amount)->annual_total_invested,
    651              SUM(lot_count)->annual_lot_count,
    652              COUNT(*)->distinct_tickers}(TR)
    653 
    654 W0 <- TR bowtie_{TR.user_id = PT.user_id} PT
    655 W1 <- alpha_{total_invested_amount/NULLIF(annual_total_invested,0)->position_weight}(W0)
    656 W  <- omega_{PARTITION BY user_id ORDER BY total_invested_amount DESC, ticker_symbol ASC;
    657              DENSE_RANK()->position_rank}(W1)
    658 
    659 C <- gamma_{user_id;
    660             SUM(position_weight*position_weight)->hhi_concentration,
    661             MAX(position_weight)->top_position_weight,
    662             MAX_IF(ticker_symbol, position_rank=1)->top_ticker}(W)
    663 
    664 IBM <- IB x M
    665 AY  <- sigma_{a.buy_date >= '2026-01-01' AND a.buy_date < '2027-01-01'}(assets a)
    666 MI0 <- IBM leftouterjoin_{IBM.user_id=a.user_id AND IBM.month_no=MONTH(a.buy_date)} AY
    667 MI  <- gamma_{user_id, month_no;
    668               SUM(COALESCE(a.quantity,0)*COALESCE(a.buy_price,0))->monthly_invested_amount}(MI0)
    669 MS  <- gamma_{user_id;
    670               AVG(monthly_invested_amount)->avg_monthly_contribution,
    671               STDDEV_SAMP(monthly_invested_amount)->contribution_stddev,
    672               COUNT_IF(monthly_invested_amount>0)->active_investing_months}(MI)
    673 
    674 R0 <- IB
    675       leftouterjoin_{IB.user_id=PT.user_id} PT
    676       leftouterjoin_{IB.user_id=C.user_id} C
    677       leftouterjoin_{IB.user_id=MS.user_id} MS
    678 R1 <- alpha_{COALESCE(annual_total_invested,0)->annual_total_invested_nz,
    679              COALESCE(annual_lot_count,0)->annual_lot_count_nz,
    680              COALESCE(distinct_tickers,0)->distinct_tickers_nz,
    681              COALESCE(avg_monthly_contribution,0)->avg_monthly_contribution_nz,
    682              COALESCE(active_investing_months,0)->active_investing_months_nz,
    683              COALESCE(active_investing_months,0)/12.0->activity_ratio,
    684              COALESCE(hhi_concentration,0)->hhi_concentration_nz,
    685              1-COALESCE(hhi_concentration,1)->diversification_index,
    686              COALESCE(top_position_weight,0)->top_position_weight_nz,
    687              COALESCE(contribution_stddev/NULLIF(avg_monthly_contribution,0),0)->contribution_volatility_cv}(R0)
    688 R  <- omega_{ORDER BY diversification_index DESC,
    689                     annual_total_invested_nz DESC,
    690                     active_investing_months_nz DESC,
    691                     user_id ASC;
    692              DENSE_RANK()->investing_annual_rank}(R1)
    693 }}}
    694 
     284    f.user_id,
     285    fm.total_earned_this_month,
     286    f.spending_budget,
     287    f.saving_budget,
     288    f.investing_budget,
     289    f.donation_budget,
     290    f.credit,
     291    ROUND((COALESCE(f.spending_budget, 0)  / 100.0) * fm.total_earned_this_month, 2) AS spending_amount,
     292    ROUND((COALESCE(f.saving_budget, 0)    / 100.0) * fm.total_earned_this_month, 2) AS saving_amount,
     293    ROUND((COALESCE(f.investing_budget, 0) / 100.0) * fm.total_earned_this_month, 2) AS investing_amount,
     294    ROUND((COALESCE(f.donation_budget, 0)  / 100.0) * fm.total_earned_this_month, 2) AS donation_amount,
     295    ROUND((COALESCE(f.credit, 0)           / 100.0) * fm.total_earned_this_month, 2) AS credit_amount
     296FROM trekr.finance_users f
     297LEFT JOIN trekr.vw_finance_current_month fm ON fm.user_id = f.user_id;
     298}}}