From 06346cfa6b1d4f1173cbd5f6d6a2410d0c6abeda Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 31 May 2026 11:05:02 -0400 Subject: [PATCH] docs(fusion_clock): bi-weekly attendance filter spec (reuse pay-period config, filters + picker) Co-Authored-By: Claude Opus 4.8 --- ...05-31-biweekly-attendance-filter-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 fusion_clock/docs/superpowers/specs/2026-05-31-biweekly-attendance-filter-design.md diff --git a/fusion_clock/docs/superpowers/specs/2026-05-31-biweekly-attendance-filter-design.md b/fusion_clock/docs/superpowers/specs/2026-05-31-biweekly-attendance-filter-design.md new file mode 100644 index 00000000..344be129 --- /dev/null +++ b/fusion_clock/docs/superpowers/specs/2026-05-31-biweekly-attendance-filter-design.md @@ -0,0 +1,105 @@ +# Fusion Clock — Bi-Weekly Attendance Filter Design + +**Date:** 2026-05-31 +**Module:** `fusion_clock` +**Status:** Approved (brainstorming) — ready for implementation plan + +--- + +## 1. Problem + +Operators reviewing **All Attendances** have no quick way to scope the list to a pay period. In Canada most payroll runs **bi-weekly**, so the common need is "show me this two-week pay period's attendances" (and step to the previous/next, or jump to an arbitrary period). The module already computes bi-weekly windows for its reports and already has the configuration — but none of it is exposed as a filter on the attendance list. + +## 2. Existing state (reused, not rebuilt) + +- **Period math already exists:** `fusion.clock.report._calculate_current_period(frequency, anchor_str, reference_date) → (start, end)` (`models/clock_report.py:457`). Handles `weekly` (7d), `biweekly` (14d), `semi_monthly`, `monthly`; uses floor division so dates *before* the anchor resolve correctly; anchor defaults to first-of-month when unset. +- **Setting already exists:** Settings → Fusion Clock → Pay Period: **Frequency** (`fusion_clock.pay_period_type`, default `biweekly`) + **Anchor Date** (`fusion_clock.pay_period_start`, `YYYY-MM-DD`). Decision (brainstorming): **reuse this as the single source of truth** — no new setting. +- **Attendance search view already inherited:** `view_hr_attendance_search_fusion_clock` (`views/hr_attendance_views.xml`) inherits `hr_attendance.hr_attendance_view_filter` and already adds custom filters — the natural home for the new period filters. +- **TZ helpers:** `get_local_day_boundaries(env, date[, employee])` and `get_local_today(env)` in `models/tz_utils.py`. + +Decisions from brainstorming: reuse the Pay Period setting; provide **both** quick filters and a picker; the window **follows the Frequency setting** (one pay period; 2 weeks by default). + +## 3. Design + +### A. Shared period math (DRY) +Extract the body of `_calculate_current_period` into a reusable, model-free helper so reports, filters, and the wizard share one implementation and never drift. + +`models/pay_period.py` (new): +```python +def compute_pay_period(frequency, anchor_str, reference_date) -> (date, date) + # identical logic to _calculate_current_period; pure function +def period_length_days(frequency) -> int | None + # 7 for 'weekly', 14 for 'biweekly'/default, None for calendar-based (semi_monthly/monthly) +def current_prev_next(frequency, anchor_str, today) -> dict + # {'current': (s,e), 'previous': (s,e), 'next': (s,e)} where + # previous = compute_pay_period(..., current_start - 1 day), + # next = compute_pay_period(..., current_end + 1 day) # works for ALL frequencies +``` +`fusion.clock.report._calculate_current_period` becomes a thin delegator to `compute_pay_period` (no behaviour change). + +### B. Quick filters on the attendance list +On `hr.attendance`, three **non-stored computed Boolean** fields, each with a `search` method (the compute returns `False` — display only; the search method does the work): +- `x_fclk_in_current_period`, `x_fclk_in_previous_period`, `x_fclk_in_next_period` + +Each `_search_*(operator, value)`: +1. Read `pay_period_type` + `pay_period_start` via `ICP.sudo().get_param`. +2. Compute the window with `current_prev_next(...)` keyed on `get_local_today(env)`. +3. Convert the date window to UTC bounds with `get_local_day_boundaries` (start → 00:00 local, end → next-day 00:00 / inclusive end-of-day). +4. Return `['&', ('check_in', '>=', start_utc), ('check_in', '<', end_excl_utc)]`. + +Add three filters to `view_hr_attendance_search_fusion_clock`: +```xml + + + +``` + +### C. "Pick Pay Period" wizard +`wizard/clock_period_picker_wizard.py` (new) — transient `fusion.clock.period.picker`: +- `date_start` (Date, required) — default = current period start (`current_prev_next(...)['current'][0]`). +- `date_end` (Date, required) — default = current period end; **editable**. +- `@api.onchange('date_start')`: if `period_length_days(freq)` is not None → `date_end = date_start + length - 1`; else (calendar frequencies) → `date_end = compute_pay_period(freq, anchor, date_start)[1]`. (User can still override `date_end` → covers "set both, or just the start and auto-calc".) +- `action_apply()` returns an `ir.actions.act_window`: + ```python + return { + 'type': 'ir.actions.act_window', 'name': f"Attendances · {date_start} – {date_end}", + 'res_model': 'hr.attendance', 'view_mode': 'list,form', + 'domain': ['&', ('check_in','>=', start_utc), ('check_in','<', end_excl_utc)], + 'target': 'current', + } + ``` + (`start_utc`/`end_excl_utc` from `get_local_day_boundaries` on `date_start` / `date_end`.) + +`wizard/clock_period_picker_views.xml` (new): a small form (date_start, date_end, **Apply** + **Cancel**) and an `ir.actions.act_window` opening it as a dialog (`target="new"`). + +### D. Entry points +- **Menu:** `views/clock_menus.xml` — add **Fusion Clock → Attendance → Bi-Weekly Period** (`sequence` after All Attendances), `groups="group_fusion_clock_manager,group_fusion_clock_team_lead"`, action = the picker wizard. +- **Dashboard tile:** add an **onViewBiweekly()** handler (opens the picker wizard act_window) and a "🗓 Bi-Weekly Period" tile in the dashboard Quick Actions, inside the existing `t-if="state.team"` block (so only leads/managers see it). + +### E. Settings +No new setting. Clarify the Anchor Date help/label in `res_config_settings_views.xml` to note it is the bi-weekly week-start used by **both** the reports and the attendance period filter. + +## 4. Permissions +Filters, menu, and dashboard tile are gated to **manager + team-lead** (the attendance list itself is already gated to them in `clock_menus.xml`). Search methods read `ir.config_parameter` via `sudo()` (config only — no employee data). The returned domains run through Odoo's normal ACL/record rules, so a team-lead still sees only their own reports' attendance rows. No new data exposure. + +## 5. Edge cases +- **No anchor set** → `compute_pay_period` falls back to first-of-month (existing behaviour); filters and picker still resolve a sane window. The picker pre-fills `date_start` with the computed current start so it is never blank. +- **Frequency = semi_monthly / monthly** → window follows it; previous/next via `current_start − 1` / `current_end + 1` handles calendar stepping; picker auto-end uses the calendar period end containing the chosen start. +- **TZ / DST** → date windows convert through `get_local_day_boundaries`, so a UTC `check_in` is matched against local pay-period days; end is exclusive next-day-00:00 to include the whole last day. +- **date_end before date_start** in the picker → `@api.constrains` raises a friendly `ValidationError`. + +## 6. Testing (`tests/test_pay_period.py`, `@tagged('-at_install','post_install','fusion_clock')`) +- `compute_pay_period` for weekly / biweekly / semi_monthly / monthly, including a reference date **before** the anchor (negative offset) and exact boundary days. +- `current_prev_next` returns contiguous, non-overlapping windows for biweekly (prev_end + 1 day == current_start, current_end + 1 == next_start). +- Create attendances spanning two bi-weekly periods; assert the **Current** search filter returns only current-period rows and **Previous** only previous-period rows. +- Wizard: default `date_start` == current period start; `onchange` sets `date_end = start + 13` for biweekly; `action_apply` returns an act_window whose domain bounds equal the local-day UTC boundaries of the chosen window. + +## 7. Out of scope (YAGNI) +Custom OWL toolbar dropdown on the list (native Filters menu + wizard instead); per-employee differing pay periods; editing the anchor from the picker; saving favourite/named periods; touching the gantt view. + +## 8. Files touched +- New: `models/pay_period.py`, `wizard/clock_period_picker_wizard.py`, `wizard/clock_period_picker_views.xml`, `tests/test_pay_period.py` +- Modify: `models/__init__.py`, `models/clock_report.py` (delegate), `models/hr_attendance.py` (3 fields + search methods), `wizard/__init__.py`, `views/hr_attendance_views.xml` (3 filters), `views/clock_menus.xml` (menu item), `views/res_config_settings_views.xml` (label text), `static/src/js/fusion_clock_dashboard.js` + `static/src/xml/fusion_clock_dashboard.xml` (tile), `__manifest__.py` (data entry for the wizard view + version bump) + +## 9. Deployment +Local test on the dev container (when available), then the standard entech path: bump version, `git commit --only` the explicit paths, push **origin + gitea**, upgrade entech (`pct exec 111`, native `odoo.service`, DB `admin`, `--http-port=0 --gevent-port=0`), verify web 200 + installed version, hard-refresh.