#:########################################################################################:#
# Jobs Tracker Processor Package                                                           #
#:########################################################################################:#
#
# TITLE:
#   Jobs Tracker Processor
#
# FILE:
#   jobs_tracker_processor.yaml
#
# VERSION:
#   V1.6 2026-07-10
#
# PURPOSE:
#   Generic processing engine for the Jobs Tracker system.
#
# OPERATION:
#   This package is the generic processing engine for the Jobs Tracker system.
#   It is not normally edited when adding, removing or changing household jobs.
#   Jobs are defined in the separate job-list package, which calls this processor
#   through script.jobs_tracker_update_job. The processor calculates whether each
#   job is Not Due, Missed, Due, Overdue or Complete, then publishes the
#   result as retained MQTT JSON to that job's configured MQTT topic. The retained
#   MQTT payload stores the last completion timestamp, so after a Home Assistant
#   restart the processor can recalculate the correct state from the last physical
#   button/action press.
#
#   Normal operation is automatic. The job-list package refreshes all jobs on a
#   regular timer and after Home Assistant starts, and it calls this processor with
#   mark_complete: true when a physical button, event, sensor, MQTT button or other
#   completion action confirms a job has been completed. A job is considered
#   Complete when its stored last_completed_ts is within the current reset period.
#
#   Daily jobs reset at 00:00 but only become Due at their configured due_time.
#   Weekly jobs reset at the week rollover, currently Monday 00:00. Monthly jobs
#   reset at the month rollover, currently the 1st day of the month at 00:00.
#
#   If the previous reset period was missed, jobs without a completion window are
#   marked Missed after the reset and before the current due time. Jobs with a
#   completion window are marked Missed after the current completion window ends.
#   This lets jobs such as morning dog feeding show Missed after the morning
#   window, but then reset to Not Due/Due again the next morning.
#
#   Once the current due time arrives, a job changes to Due, then Overdue after
#   the configured threshold unless the job has reached the end of its completion
#   window, in which case it changes to Missed. Completing a job early is allowed
#   once the current reset period has started.
#
# SETTINGS:
#   The job-list package passes these settings into this processor for each job:
#
#     job_id:
#       Machine-safe ID used for matching and MQTT topic naming.
#       Example: dog_feeding_morning
#
#     job_name:
#       Human-readable job name used in the retained MQTT JSON.
#       Example: Dog Feeding Morning
#
#     repeat_type:
#       Schedule type. Valid values are daily, weekly or monthly.
#
#     due_time:
#       Time the job is due, in HH:MM 24-hour format.
#       Example: "06:00"
#
#     due_weekday:
#       Weekly jobs only. Monday=0, Tuesday=1, Wednesday=2, Thursday=3,
#       Friday=4, Saturday=5, Sunday=6.
#
#     due_day:
#       Monthly jobs only. Use 1-28 for best reliability across all months.
#
#     mqtt_topic:
#       Retained MQTT state topic for this job.
#       Example: viewroad-status/jobs_tracker/dog_feeding_morning
#
#     state_entity:
#       MQTT sensor entity that reads this job's retained JSON payload.
#       Example: sensor.jobs_tracker_dog_feeding_morning
#
#     mark_complete:
#       false when refreshing/calculating the job state.
#       true when a physical/manual completion action has just occurred.
#
#     completion_window_start / completion_window_end:
#       Optional HH:MM window used for jobs that share a physical button or have
#       a clear daily completion window. If the current window ends before the
#       job is completed, the job is marked Missed instead of remaining Overdue.
#       Example: "00:00" to "11:59" for morning dog feeding.
#
#   Current built-in overdue thresholds are:
#     - Daily jobs:   2 hours after due_time
#     - Weekly jobs:  2 days after due_time
#     - Monthly jobs: 2 weeks after due_time
#
# VERSION HISTORY:
#   V1.6 2026-07-10
#     - Renamed displayed state from Missed to Missed.
#     - Added optional completion_window_start and completion_window_end fields.
#     - Windowed jobs now become Missed when the completion window has ended.
#     - Windowed jobs reset normally into Not Due / Due in the next period.
#
#   V1.5 2026-07-07
#     - Documentation update for the refactored registry-driven job-list package.
#     - No functional processing change from V1.4.
#
#   V1.4 2026-07-06
#     - Added Missed state.
#     - If the previous reset period was missed, the job now shows Previous
#       Missed until the current period's due time.
#     - Missed applies to daily, weekly and monthly jobs.
#     - Due now starts at the configured due time for daily, weekly and monthly
#       jobs.
#     - Early completion remains supported after the current reset period starts.
#
#   V1.3.1 2026-06-20
#     - Corrected script field definitions so Home Assistant reliably creates
#       script.jobs_tracker_update_job.
#
#   V1.3 2026-06-20
#     - Daily jobs reset at 00:00 but do not become Due until their configured
#       due time.
#     - Added Not Due state.
#     - Completion is recalculated from retained last_completed_ts after restart.
#
#:########################################################################################:#

script:
  jobs_tracker_update_job:
    alias: "Jobs Tracker - Update Job"
    description: "Updates one job's retained MQTT JSON state."
    mode: queued
    max: 20

    fields:
      job_id:
        description: "Machine-safe job ID."
        example: "dog_feeding_morning"
      job_name:
        description: "Human-readable job name."
        example: "Dog Feeding Morning"
      repeat_type:
        description: "daily, weekly or monthly."
        example: "daily"
      due_time:
        description: "Time the job is due, HH:MM."
        example: "06:00"
      due_weekday:
        description: "For weekly jobs only. Monday=0, Friday=4, Sunday=6."
        example: 4
      due_day:
        description: "For monthly jobs only. Use 1-28."
        example: 1
      mqtt_topic:
        description: "MQTT topic used for retained job state."
        example: "viewroad-status/jobs_tracker/dog_feeding_morning"
      state_entity:
        description: "MQTT sensor entity that reads this job's retained JSON."
        example: "sensor.jobs_tracker_dog_feeding_morning"
      mark_complete:
        description: "Set true when a human has completed the job."
        example: true
      completion_window_start:
        description: "Optional HH:MM completion window start."
        example: "00:00"
      completion_window_end:
        description: "Optional HH:MM completion window end."
        example: "11:59"

    sequence:
      - variables:
          mark_complete_bool: "{{ mark_complete | default(false) | bool }}"
          repeat_type_safe: "{{ repeat_type | default('daily') | lower }}"
          due_time_safe: "{{ due_time | default('00:00') }}"
          due_hour: "{{ due_time_safe.split(':')[0] | int(0) }}"
          due_minute: "{{ due_time_safe.split(':')[1] | int(0) }}"
          due_seconds: "{{ ((due_hour | int) * 3600) + ((due_minute | int) * 60) }}"
          due_weekday_safe: "{{ due_weekday | default(0) | int(0) }}"
          due_day_safe: "{{ due_day | default(1) | int(1) }}"
          now_ts: "{{ as_timestamp(now()) }}"
          today_midnight_ts: "{{ as_timestamp(today_at('00:00')) }}"
          topic_safe: "{{ mqtt_topic | default('viewroad-status/jobs_tracker/' ~ job_id) }}"
          completion_window_start_safe: "{{ completion_window_start | default('') }}"
          completion_window_end_safe: "{{ completion_window_end | default('') }}"
          has_completion_window: >-
            {{
              completion_window_start_safe not in ['', none, 'unknown', 'unavailable']
              and completion_window_end_safe not in ['', none, 'unknown', 'unavailable']
            }}

      #:####################################################################################:#
      # Current and next reset periods                                                       #
      #:####################################################################################:#

      - variables:
          current_period_start_ts: >-
            {% if repeat_type_safe == 'daily' %}
              {{ today_midnight_ts | float(0) }}
            {% elif repeat_type_safe == 'weekly' %}
              {{ (today_midnight_ts | float(0)) - ((now().weekday() | int) * 86400) }}
            {% elif repeat_type_safe == 'monthly' %}
              {{ as_timestamp(now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)) }}
            {% else %}
              {{ today_midnight_ts | float(0) }}
            {% endif %}

          next_period_start_ts: >-
            {% if repeat_type_safe == 'daily' %}
              {{ (today_midnight_ts | float(0)) + 86400 }}
            {% elif repeat_type_safe == 'weekly' %}
              {{ ((today_midnight_ts | float(0)) - ((now().weekday() | int) * 86400)) + 604800 }}
            {% elif repeat_type_safe == 'monthly' %}
              {% set next_month = (now().replace(day=28) + timedelta(days=4)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) %}
              {{ as_timestamp(next_month) }}
            {% else %}
              {{ (today_midnight_ts | float(0)) + 86400 }}
            {% endif %}

      #:####################################################################################:#
      # Previous reset period                                                                #
      #:####################################################################################:#

      - variables:
          previous_period_start_ts: >-
            {% if repeat_type_safe == 'daily' %}
              {{ (current_period_start_ts | float(0)) - 86400 }}
            {% elif repeat_type_safe == 'weekly' %}
              {{ (current_period_start_ts | float(0)) - 604800 }}
            {% elif repeat_type_safe == 'monthly' %}
              {% set first_day_this_month = now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) %}
              {% set last_day_previous_month = first_day_this_month - timedelta(days=1) %}
              {{ as_timestamp(last_day_previous_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0)) }}
            {% else %}
              {{ (current_period_start_ts | float(0)) - 86400 }}
            {% endif %}

      #:####################################################################################:#
      # Due times                                                                            #
      #:####################################################################################:#

      - variables:
          current_due_ts: >-
            {% if repeat_type_safe == 'daily' %}
              {{ (current_period_start_ts | float(0)) + (due_seconds | int) }}
            {% elif repeat_type_safe == 'weekly' %}
              {{ (current_period_start_ts | float(0)) + ((due_weekday_safe | int) * 86400) + (due_seconds | int) }}
            {% elif repeat_type_safe == 'monthly' %}
              {{ as_timestamp(now().replace(day=(due_day_safe | int), hour=(due_hour | int), minute=(due_minute | int), second=0, microsecond=0)) }}
            {% else %}
              {{ (current_period_start_ts | float(0)) + (due_seconds | int) }}
            {% endif %}

          previous_due_ts: >-
            {% if repeat_type_safe == 'daily' %}
              {{ (previous_period_start_ts | float(0)) + (due_seconds | int) }}
            {% elif repeat_type_safe == 'weekly' %}
              {{ (previous_period_start_ts | float(0)) + ((due_weekday_safe | int) * 86400) + (due_seconds | int) }}
            {% elif repeat_type_safe == 'monthly' %}
              {% set first_day_this_month = now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) %}
              {% set last_day_previous_month = first_day_this_month - timedelta(days=1) %}
              {{ as_timestamp(last_day_previous_month.replace(day=(due_day_safe | int), hour=(due_hour | int), minute=(due_minute | int), second=0, microsecond=0)) }}
            {% else %}
              {{ (previous_period_start_ts | float(0)) + (due_seconds | int) }}
            {% endif %}

          next_due_ts: >-
            {% if repeat_type_safe == 'daily' %}
              {{ (next_period_start_ts | float(0)) + (due_seconds | int) }}
            {% elif repeat_type_safe == 'weekly' %}
              {{ (next_period_start_ts | float(0)) + ((due_weekday_safe | int) * 86400) + (due_seconds | int) }}
            {% elif repeat_type_safe == 'monthly' %}
              {% set next_month = (now().replace(day=28) + timedelta(days=4)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) %}
              {{ as_timestamp(next_month.replace(day=(due_day_safe | int), hour=(due_hour | int), minute=(due_minute | int), second=0, microsecond=0)) }}
            {% else %}
              {{ (next_period_start_ts | float(0)) + (due_seconds | int) }}
            {% endif %}

          overdue_after_minutes: >-
            {% if repeat_type_safe == 'daily' %}
              120
            {% elif repeat_type_safe == 'weekly' %}
              2880
            {% elif repeat_type_safe == 'monthly' %}
              20160
            {% else %}
              120
            {% endif %}

      #:####################################################################################:#
      # Completion window timing                                                             #
      #:####################################################################################:#
      #
      # For windowed jobs, Missed begins when the completion window has ended.
      # The window is anchored to the current due date. End times are treated as
      # inclusive through the final minute, so "11:59" ends at 11:59:59.

      - variables:
          completion_window_start_seconds: >-
            {% if has_completion_window | bool %}
              {% set t = completion_window_start_safe.split(':') %}
              {{ ((t[0] | int(0)) * 3600) + ((t[1] | int(0)) * 60) }}
            {% else %}
              0
            {% endif %}

          completion_window_end_seconds: >-
            {% if has_completion_window | bool %}
              {% set t = completion_window_end_safe.split(':') %}
              {{ ((t[0] | int(23)) * 3600) + ((t[1] | int(59)) * 60) + 59 }}
            {% else %}
              86399
            {% endif %}

          current_due_day_start_ts: >-
            {% set d = (current_due_ts | float(0)) | timestamp_custom('%Y-%m-%d', true) %}
            {{ as_timestamp(strptime(d ~ ' 00:00:00', '%Y-%m-%d %H:%M:%S')) }}

          current_completion_window_start_ts: >-
            {% if has_completion_window | bool %}
              {{ (current_due_day_start_ts | float(0)) + (completion_window_start_seconds | int(0)) }}
            {% else %}
              0
            {% endif %}

          current_completion_window_end_ts: >-
            {% if has_completion_window | bool %}
              {% set start_seconds = completion_window_start_seconds | int(0) %}
              {% set end_seconds = completion_window_end_seconds | int(86399) %}
              {% set base_end = (current_due_day_start_ts | float(0)) + end_seconds %}
              {% if end_seconds < start_seconds %}
                {{ base_end + 86400 }}
              {% else %}
                {{ base_end }}
              {% endif %}
            {% else %}
              0
            {% endif %}

      #:####################################################################################:#
      # Restore retained completion timestamp                                                #
      #:####################################################################################:#

      - variables:
          stored_last_completed_ts: >-
            {% set v = state_attr(state_entity, 'last_completed_ts') %}
            {{ 0 if v in [none, '', 'unknown', 'unavailable'] else v | float(0) }}

          last_completed_ts: >-
            {{ now_ts if mark_complete_bool else stored_last_completed_ts }}

      #:####################################################################################:#
      # Completion and timing state                                                          #
      #:####################################################################################:#

      - variables:
          complete_bool: >-
            {{ (last_completed_ts | float(0)) >= (current_period_start_ts | float(0)) }}

          previous_completed_bool: >-
            {{
              (last_completed_ts | float(0)) >= (previous_period_start_ts | float(0))
              and (last_completed_ts | float(0)) < (current_period_start_ts | float(0))
            }}

          previous_missed_bool: >-
            {{ not (previous_completed_bool | bool) }}

          due_started_bool: >-
            {{ (now_ts | float(0)) >= (current_due_ts | float(0)) }}

          overdue_started_bool: >-
            {{ (now_ts | float(0)) >= ((current_due_ts | float(0)) + ((overdue_after_minutes | int(0)) * 60)) }}

          previous_missed_active_bool: >-
            {{
              previous_missed_bool | bool
              and not (complete_bool | bool)
              and not (due_started_bool | bool)
            }}

          completion_window_missed_bool: >-
            {{
              has_completion_window | bool
              and not (complete_bool | bool)
              and (now_ts | float(0)) > (current_completion_window_end_ts | float(0))
            }}

          missed_bool: >-
            {{
              completion_window_missed_bool | bool
              or (
                previous_missed_active_bool | bool
                and not (has_completion_window | bool)
              )
            }}

          minutes_since_required: >-
            {% if (now_ts | float(0)) <= (current_due_ts | float(0)) %}
              0
            {% else %}
              {{ (((now_ts | float(0)) - (current_due_ts | float(0))) / 60) | round(0) | int }}
            {% endif %}

          hours_since_required: >-
            {% if (now_ts | float(0)) <= (current_due_ts | float(0)) %}
              0
            {% else %}
              {{ (((now_ts | float(0)) - (current_due_ts | float(0))) / 3600) | round(0) | int }}
            {% endif %}

      #:####################################################################################:#
      # Display state                                                                        #
      #:####################################################################################:#

      - variables:
          job_status: >-
            {% if complete_bool | bool %}
              Complete
            {% elif missed_bool | bool %}
              Missed
            {% elif not (due_started_bool | bool) %}
              Not Due
            {% elif overdue_started_bool | bool %}
              Overdue
            {% else %}
              Due
            {% endif %}

          display_due_ts: >-
            {% if complete_bool | bool %}
              {{ next_due_ts | float(0) }}
            {% else %}
              {{ current_due_ts | float(0) }}
            {% endif %}

          overdue_minutes: >-
            {% if job_status | trim == 'Overdue' %}
              {{ minutes_since_required | int(0) }}
            {% else %}
              0
            {% endif %}

          overdue_hours: >-
            {% if job_status | trim == 'Overdue' %}
              {{ hours_since_required | int(0) }}
            {% else %}
              0
            {% endif %}

          hours_since_completed: >-
            {% if (last_completed_ts | float(0)) > 0 %}
              {{ (((now_ts | float(0)) - (last_completed_ts | float(0))) / 3600) | round(0) | int }}
            {% else %}
              unknown
            {% endif %}

          last_completed_time: >-
            {% if (last_completed_ts | float(0)) > 0 %}
              {{ (last_completed_ts | float(0)) | timestamp_custom('%H:%M', true) }}
            {% else %}
              Never
            {% endif %}

          last_completed_human: >-
            {% set ts = last_completed_ts | float(0) %}
            {% if ts <= 0 %}
              Never
            {% else %}
              {% set d = ts | timestamp_custom('%Y-%m-%d', true) %}
              {% set t = ts | timestamp_custom('%H:%M', true) %}
              {% set today = now().strftime('%Y-%m-%d') %}
              {% set yesterday = (as_timestamp(today_at('00:00')) - 86400) | timestamp_custom('%Y-%m-%d', true) %}
              {% set completed_midnight = as_timestamp(strptime(d ~ ' 00:00:00', '%Y-%m-%d %H:%M:%S')) %}
              {% set days_ago = ((as_timestamp(today_at('00:00')) - completed_midnight) / 86400) | round(0) | int %}
              {% if d == today %}
                Today @ {{ t }}
              {% elif d == yesterday %}
                Yesterday @ {{ t }}
              {% elif days_ago < 7 %}
                Last {{ ts | timestamp_custom('%A', true) }} @ {{ t }}
              {% else %}
                {{ days_ago }} days ago @ {{ t }}
              {% endif %}
            {% endif %}

          next_due_human: >-
            {% set ts = display_due_ts | float(0) %}
            {% set d = ts | timestamp_custom('%Y-%m-%d', true) %}
            {% set t = ts | timestamp_custom('%H:%M', true) %}
            {% set today = now().strftime('%Y-%m-%d') %}
            {% set tomorrow = (as_timestamp(today_at('00:00')) + 86400) | timestamp_custom('%Y-%m-%d', true) %}
            {% set yesterday = (as_timestamp(today_at('00:00')) - 86400) | timestamp_custom('%Y-%m-%d', true) %}
            {% set due_midnight = as_timestamp(strptime(d ~ ' 00:00:00', '%Y-%m-%d %H:%M:%S')) %}
            {% set days_diff = ((due_midnight - as_timestamp(today_at('00:00'))) / 86400) | round(0) | int %}
            {% if d == today %}
              Today @ {{ t }}
            {% elif d == tomorrow %}
              Tomorrow @ {{ t }}
            {% elif d == yesterday %}
              Yesterday @ {{ t }}
            {% elif days_diff > 1 and days_diff <= 7 %}
              Next {{ ts | timestamp_custom('%A', true) }} @ {{ t }}
            {% elif days_diff < 0 and days_diff > -7 %}
              Last {{ ts | timestamp_custom('%A', true) }} @ {{ t }}
            {% elif days_diff > 7 %}
              In {{ days_diff }} days @ {{ t }}
            {% elif days_diff < -7 %}
              {{ 0 - days_diff }} days ago @ {{ t }}
            {% else %}
              {{ ts | timestamp_custom('%Y-%m-%d @ %H:%M', true) }}
            {% endif %}

      #:####################################################################################:#
      # Publish retained MQTT JSON                                                           #
      #:####################################################################################:#

      - variables:
          completed_for_period_ts_safe: >-
            {% if complete_bool | bool %}
              {{ current_period_start_ts | float(0) }}
            {% else %}
              0
            {% endif %}

      - action: mqtt.publish
        data:
          topic: "{{ topic_safe }}"
          retain: true
          qos: 1
          payload: >-
            {
              "job_id": {{ job_id | tojson }},
              "job_name": {{ job_name | tojson }},
              "repeat": {{ repeat_type_safe | tojson }},
              "due_time": {{ due_time_safe | tojson }},
              "due_weekday": {{ due_weekday_safe | int }},
              "due_day": {{ due_day_safe | int }},
              "state": {{ job_status | trim | tojson }},
              "complete": {{ complete_bool | bool | tojson }},
              "last_completed_ts": {{ last_completed_ts | float(0) }},
              "last_completed_time": {{ last_completed_time | tojson }},
              "last_completed_human": {{ last_completed_human | tojson }},
              "previous_period_start_ts": {{ previous_period_start_ts | float(0) }},
              "previous_due_ts": {{ previous_due_ts | float(0) }},
              "previous_completed": {{ previous_completed_bool | bool | tojson }},
              "previous_missed": {{ previous_missed_bool | bool | tojson }},
              "previous_missed_active": {{ previous_missed_active_bool | bool | tojson }},
              "missed": {{ missed_bool | bool | tojson }},
              "completion_window_start": {{ completion_window_start_safe | tojson }},
              "completion_window_end": {{ completion_window_end_safe | tojson }},
              "has_completion_window": {{ has_completion_window | bool | tojson }},
              "completion_window_missed": {{ completion_window_missed_bool | bool | tojson }},
              "current_completion_window_start_ts": {{ current_completion_window_start_ts | float(0) }},
              "current_completion_window_end_ts": {{ current_completion_window_end_ts | float(0) }},
              "completed_for_period_ts": {{ completed_for_period_ts_safe | float(0) }},
              "completed_for_due_ts": {{ completed_for_period_ts_safe | float(0) }},
              "current_period_start_ts": {{ current_period_start_ts | float(0) }},
              "next_period_start_ts": {{ next_period_start_ts | float(0) }},
              "current_due_ts": {{ current_due_ts | float(0) }},
              "next_due_ts": {{ next_due_ts | float(0) }},
              "display_due_ts": {{ display_due_ts | float(0) }},
              "next_due_human": {{ next_due_human | tojson }},
              "due_started": {{ due_started_bool | bool | tojson }},
              "overdue_started": {{ overdue_started_bool | bool | tojson }},
              "minutes_since_required": {{ minutes_since_required | int(0) }},
              "hours_since_required": {{ hours_since_required | int(0) }},
              "overdue_after_minutes": {{ overdue_after_minutes | int(0) }},
              "overdue_minutes": {{ overdue_minutes | int(0) }},
              "overdue_hours": {{ overdue_hours | int(0) }},
              "hours_since_completed": {{ hours_since_completed | tojson }},
              "last_updated_local": {{ ((now_ts | float(0)) | timestamp_custom('%Y-%m-%d %H:%M:%S', true)) | tojson }},
              "mqtt_topic": {{ topic_safe | tojson }}
            }
