This commit is contained in:
1 parent
03841c6e3d
commit
036a8c0e1e
32 files changed
+1805
-89
No files matched your search
@@ -0,0 +1,22 @@
|
||||
# 01 — Open plan statistics for calculated leave usage
|
||||
|
||||
**What to build:** The user opens a statistics popup from the calendar and sees how many new leave days the displayed valid plan uses, including a valid plan retained after cancellation. This establishes a usable first slice that later tickets extend with the other statistics.
|
||||
|
||||
**Blocked by:** None — can start immediately.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**GitLab:** [#65](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/65)
|
||||
|
||||
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), in milestone %3: V1 stats. Follow the domain glossary and ADRs 0002, 0004, 0006, and 0007.
|
||||
|
||||
- [ ] A labelled calendar control opens one statistics popup for the latest calculated valid plan. Opening it runs no optimization and changes no planning data.
|
||||
- [ ] The popup shows the number of leave placement dates with an explicit leave-day label. Declared non-working days consume no balance and are excluded from this count. A valid plan with no placements displays 0 and still permits opening the popup.
|
||||
- [ ] A retained valid plan from an incomplete calculation exposes the same popup with a prominent "Partial plan" label. Invalid input, a planning conflict, or an incomplete calculation without a retained valid plan exposes no statistics.
|
||||
- [ ] Statistics refer to the producing calculation and its submitted inputs. Editing balances, rules, declarations, or the horizon, inspecting historical trace steps, and exploring hypothetical dates do not change the displayed count.
|
||||
- [ ] A new valid outcome replaces the statistics. A new outcome without a valid plan clears them, including any already open popup. Late replies from an older calculation cannot restore or overwrite them.
|
||||
- [ ] Popup access is available with the valid outcome while trace delivery is pending or has failed. Reuse the existing result lifecycle and dialog patterns without introducing another calculation or trace-dependent summary.
|
||||
- [ ] The control and popup work with the existing language settings, keyboard opening and dismissal, visible focus, focus containment and restoration, meaningful accessible labels, and a readable mobile layout.
|
||||
- [ ] Existing behavior tests verify complete and retained partial plans, zero placements, declared-day exclusion, pending input edits, delayed or failed trace delivery, stale replies, and replacement by an outcome without a valid plan. Verify keyboard behavior and run the repository's existing tests, lint, formatting, type checks, and production build.
|
||||
|
||||
This ticket is independently demonstrable by calculating a plan and opening its leave-usage summary. It does not depend on declared-day period aggregation or earned-leave acquisition changes.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 02 — Show work-sequence and time-off-period lengths
|
||||
|
||||
**What to build:** The statistics popup shows average and longest working stretches, average and longest complete breaks, and non-working-day gain, using the calculated plan's shared calendar semantics.
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 01 — Open plan statistics for calculated leave usage.
|
||||
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/62), which establishes time-off periods and work sequences that include declared non-working days.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**GitLab:** [#66](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/66)
|
||||
|
||||
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#7](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/7). The existing longest work-sequence metric from #28 is reused. Follow the domain glossary and ADRs 0004, 0006, and 0007.
|
||||
|
||||
- [ ] Add five labelled metrics: non-working-day gain, average work-sequence length, longest work-sequence length, average time-off period length, and longest time-off period length. Work lengths use working days; break lengths use calendar days.
|
||||
- [ ] The work-sequence average includes every non-empty sequence inside the submitted horizon, including both edges. Weekends and French public holidays neither count nor split a sequence; leave placements and declared non-working days interrupt it. Consecutive days off introduce no zero-length samples.
|
||||
- [ ] Sequences of 10, 5, and 15 working days produce an average of 10 and a longest length of 15. Fractional averages remain representable and calculations do not prematurely round the underlying values.
|
||||
- [ ] Break statistics use full canonical time-off periods, including merged declarations and placements and contiguous non-working dates immediately outside the horizon. Friday and Monday off around a holiday-free weekend produce one 4-calendar-day period. A declared Friday with no new placements produces a 3-day Friday–Sunday period.
|
||||
- [ ] Non-working-day gain counts distinct weekend and public-holiday dates in these periods. A weekend public holiday contributes once; a declared day does not itself become additional weekend or holiday gain.
|
||||
- [ ] Saved declarations outside the horizon do not inflate in-horizon work counts or create isolated out-of-horizon periods. Adjacent declarations may extend a period anchored inside the horizon under the shared calendar contract.
|
||||
- [ ] Empty samples remain distinguishable from measured zero. A valid plan without work sequences retains a longest work-sequence length of 0; missing average samples and a missing longest time-off period retain an explicit empty data state.
|
||||
- [ ] All five figures inherit the popup's snapshot, trace independence, and partial-plan behavior. Any new outcome facts survive direct planning, worker transport, and retained-plan checkpoints and describe the actual displayed plan.
|
||||
- [ ] Behavior tests cover fractional means, both horizon edges, contiguous days off, intervening weekends and holidays, declarations without new placements, merged periods, adjacent context dates, and empty samples. Check rendered units and values, complete and retained partial outcomes, and the repository's existing tests, lint, formatting, type checks, and production build.
|
||||
|
||||
Reuse #62's calendar and period definitions rather than adding a conflicting calendar model. These descriptive metrics do not change the planning objective order.
|
||||
|
||||
Proposed presentation default, not explicitly confirmed: show "—" for an average without samples and for the longest time-off period when none exists. Decimal precision follows existing presentation conventions; it does not change the underlying metric.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 03 — Show gross earned leave by generating rule
|
||||
|
||||
**What to build:** The statistics popup shows all leave earned by the calculated plan before consumption, with a breakdown by the rules that generated it, including entitlements that were fully used.
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 01 — Open plan statistics for calculated leave usage.
|
||||
- [#64 — Appliquer les motifs IF_THEN et les droits acquis aux jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/64), which establishes finalized earned entitlements and the fixed Europe/Paris calculation-date cutoff.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**GitLab:** [#67](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/67)
|
||||
|
||||
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#8](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/8). Follow the domain glossary and ADRs 0004, 0006, and 0007.
|
||||
|
||||
- [ ] Display gross earned leave from every applicable generating rule, including Samedi Malin, with a per-rule breakdown whose quantities sum to the total.
|
||||
- [ ] A plan earning 3 days and consuming 2 reports 3 earned days. Fully consumed earned entitlements remain included even when absent from remaining balances; generating a future entitlement does not require consuming it inside the horizon.
|
||||
- [ ] Repeated satisfied occurrences contribute their generated quantities once per generating rule and occurrence. Distinct rules retain separate attribution even when their names match or they share contributing dates. Occurrences satisfied by declarations, placements, or both use the finalized entitlement semantics from #64.
|
||||
- [ ] Include only entitlements becoming available strictly after the producing calculation's captured Europe/Paris calendar date. Availability before or on that date is excluded; availability after the planning horizon is included. Use the finalized availability after the complete generating time-off period, including later period extensions.
|
||||
- [ ] Only entitlements generated by the final or retained valid plan contribute. Rejected candidates, abandoned branches, provisional earnings, and hypothetical alternatives cannot inflate the total.
|
||||
- [ ] Rule labels come from the producing calculation's immutable rule names and attribution. Renaming or deleting pending rules, changing balances, or crossing a calendar-date boundary after calculation does not change this result.
|
||||
- [ ] Carry reliable gross quantities and generating-rule attribution with complete outcomes and retained partial plans through direct planning, worker delivery, and cancellation checkpoints. Reuse #64's captured calculation date; do not recapture it on opening the popup, reconstruct earnings from remaining balances or trace history, or infer attribution by parsing balance identifiers.
|
||||
- [ ] A valid plan with no qualifying earnings shows a total of 0. Trace readiness and failure do not gate the total or breakdown, and partial plans retain their prominent label.
|
||||
- [ ] Fixed-clock behavior tests verify partial and full consumption, multiple rules and repeated occurrences, shared contributing dates, availability before/on/after the reference date and beyond the horizon, period extensions, original rule names, and cancellation retaining a different state from the latest candidate. Assert the rendered total and breakdown through the direct and worker paths and run the repository's existing tests, lint, formatting, type checks, and production build.
|
||||
|
||||
Presentation proposal, not an agreed requirement: show contributing rules only, omitting zero-earning rows. The total and all positive contributions are required regardless of that display choice.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 04 — Count public holidays connected to a weekend
|
||||
|
||||
**What to build:** The statistics popup counts weekday public holidays joined to a weekend by the calculated plan's time-off periods, including connections through leave and adjacent holidays beyond the horizon.
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 01 — Open plan statistics for calculated leave usage.
|
||||
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/62), which provides shared time-off periods including declared non-working days and adjacent context dates.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**GitLab:** [#68](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/68)
|
||||
|
||||
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#9](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/9). Follow the domain glossary and ADRs 0004, 0006, and 0007.
|
||||
|
||||
- [ ] Show the number of distinct Monday-to-Friday French public-holiday dates in planned time-off periods containing at least one Saturday or Sunday. Each qualifying period contains at least one leave placement or declared non-working day.
|
||||
- [ ] Count direct Monday/Friday connections and connections through leave, such as a Tuesday holiday joined to a weekend by Monday off. Declared non-working days can provide the same connection without consuming a balance.
|
||||
- [ ] Exclude Saturday and Sunday public holidays, naturally long weekends without placed or declared leave, and weekday-only time-off periods that do not reach a weekend. A declaration never becomes a public holiday.
|
||||
- [ ] Include a qualifying holiday immediately outside the submitted horizon when it belongs to the period's full contiguous span. Friday leave followed by a weekend and a Monday holiday outside the horizon includes that Monday holiday.
|
||||
- [ ] Each holiday date contributes once. This is a date count, independent of the weighted public-holiday week value used in optimization; a qualifying Wednesday holiday contributes 1, not 3.
|
||||
- [ ] A valid plan with no qualifying dates displays 0. Complete and retained partial plans use their own producing outcomes and calendar facts, independently of trace delivery or pending input edits.
|
||||
- [ ] Behavior tests verify direct and bridged connections, declaration-only connections, weekend-holiday exclusion, natural long weekends, breaks reaching no weekend, adjacent out-of-horizon holidays, and duplicate dates. Assert the visible count, direct/worker consistency, and retained-plan behavior; run the repository's existing tests, lint, formatting, type checks, and production build.
|
||||
|
||||
This slice uses the canonical periods supplied by #62 and can proceed independently of ticket 02's descriptive length statistics. It does not change optimization weights or placement decisions.
|
||||
@@ -0,0 +1,26 @@
|
||||
# 05 — Warn about weeks with one workday
|
||||
|
||||
**What to build:** The statistics popup identifies undesirable full weeks with exactly one day remaining to work, showing both how many such weeks exist and the exact date of each lone workday.
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 01 — Open plan statistics for calculated leave usage.
|
||||
- [#61 — Saisir, sauvegarder et figer les jours déjà posés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/61), which carries fixed declared non-working days with the producing calculation and its outcomes.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**GitLab:** [#69](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/69)
|
||||
|
||||
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including the clarification that working only one day in a full week is undesirable. Follow the domain glossary and ADRs 0004, 0006, and 0007.
|
||||
|
||||
- [ ] Count Monday-to-Sunday calendar weeks whose five Monday-to-Friday dates all lie inside the submitted half-open horizon and that contain exactly one remaining working day after excluding French public holidays, leave placements, and declared non-working days.
|
||||
- [ ] Four weekdays off and one remaining workday qualify. Weeks with zero or two remaining workdays do not qualify, including two-workday weeks containing separate one-day work sequences.
|
||||
- [ ] Exclude clipped workweeks at both horizon edges, even if only one workday happens to be visible. A week whose five weekdays are all inside the horizon still qualifies when its Saturday or Sunday lies outside it.
|
||||
- [ ] When the count is positive, show a clear warning and one chronological entry per affected week, identifying its Monday-to-Sunday date range and the exact date of its lone workday. Localized dates include enough year context to disambiguate multi-year horizons and weeks spanning year boundaries.
|
||||
- [ ] A valid plan with no matching weeks displays a count of 0 and no matching-week warning entries. The warning remains understandable without relying solely on color and is readable with keyboard, assistive technology, and mobile layout.
|
||||
- [ ] Declarations alone can create a qualifying week without new placements. Saved declarations outside the horizon do not create extra warnings. Use the shared working-day calendar and the producing plan's fixed declarations rather than its count of short work sequences.
|
||||
- [ ] The count and dated entries inherit snapshot stability, replacement and clearing behavior, trace independence, and the partial-plan label. They describe the actual retained plan on cancellation, not a later candidate or a historical or hypothetical view.
|
||||
- [ ] The feature only reports the pattern; it does not move leave, change planning objectives, trigger recalculation, or present a valid plan as a planning conflict.
|
||||
- [ ] Behavior tests cover one/zero/two workdays, separated one-day sequences, declaration-only weeks, holiday exclusions, clipped horizon edges, all weekdays inside with the weekend outside, month/year boundaries, and complete versus retained partial outcomes. Assert both counts and displayed week/workday dates through existing planning and calendar tests, including worker/cancellation behavior; run the repository's existing tests, lint, formatting, type checks, and production build.
|
||||
|
||||
This diagnostic depends on fixed declarations from #61, but does not require time-off-period aggregation from #62 or the other statistics tickets.
|
||||
+25
@@ -40,6 +40,9 @@ A working day on which the user declares they will not work, identified by a CP,
|
||||
**Public holiday**:
|
||||
A French calendar date that cannot receive a leave placement and contributes to a time-off period.
|
||||
|
||||
**Public holiday connected to a weekend**:
|
||||
A distinct Monday-to-Friday public-holiday date in a planned time-off period containing at least one Saturday or Sunday, including adjacent holiday dates outside the planning horizon; the connection may be direct or through leave placements or declared non-working days. Weekend public holidays and naturally long weekends without a leave placement or declared non-working day are excluded.
|
||||
|
||||
**Public-holiday week**:
|
||||
A Monday-to-Friday window containing a weekday French public holiday where every other working day is a leave placement or declared non-working day.
|
||||
|
||||
@@ -54,19 +57,35 @@ A leave placement from a deferred balance that completes a public-holiday week,
|
||||
|
||||
**Time-off period**:
|
||||
A contiguous period containing at least one leave placement or declared non-working day, together with any adjacent non-working dates, including those immediately outside the planning horizon.
|
||||
_Avoid_: leave sequence
|
||||
|
||||
**Time-off duration**:
|
||||
The number of distinct calendar dates covered by all planned time-off periods.
|
||||
|
||||
**Average time-off period length**:
|
||||
The arithmetic mean duration in calendar days of all planned time-off periods, including adjacent non-working dates outside the planning horizon.
|
||||
|
||||
**Longest time-off period length**:
|
||||
The greatest duration in calendar days among the planned time-off periods, including adjacent non-working dates outside the planning horizon.
|
||||
|
||||
**Non-working-day gain**:
|
||||
The distinct weekend and public-holiday dates contained in planned time-off periods. Leave placement dates themselves do not contribute to this gain.
|
||||
|
||||
**Work sequence**:
|
||||
The working days in an interval delimited by planned time-off periods or by the planning horizon edges. Intervening weekends and public holidays neither count nor split the sequence.
|
||||
|
||||
**Average work-sequence length**:
|
||||
The arithmetic mean number of working days across all non-empty work sequences within the planning horizon, including the sequences before the first time-off period and after the last.
|
||||
|
||||
**Longest work-sequence length**:
|
||||
The greatest number of working days in any work sequence within the planning horizon, including the sequences at both horizon edges.
|
||||
|
||||
**Short work sequence**:
|
||||
A work sequence of fewer than five working days between two planned time-off periods.
|
||||
|
||||
**Week with one workday**:
|
||||
An undesirable Monday-to-Sunday calendar week with exactly one working day remaining after excluding leave placements and declared non-working days. All five Monday-to-Friday dates must fall within the planning horizon.
|
||||
|
||||
**Planning rule**:
|
||||
A user instruction that requests leave placements around a calendar opportunity or inside a date window. Its force is either an obligation or a preference.
|
||||
|
||||
@@ -94,6 +113,9 @@ The relationship in which a plan fulfils a planning rule. One compatible leave p
|
||||
**Placement provenance**:
|
||||
The consumed leave entitlement and every planning rule satisfied by a leave placement.
|
||||
|
||||
**Plan statistics**:
|
||||
The numerical summary of a calculated valid plan's leave usage, time-off periods, and work patterns, including a valid plan retained by an incomplete calculation. It reflects that calculation's horizon, balances, rules, declared non-working days, and Europe/Paris calendar date.
|
||||
|
||||
**Plan explanation**:
|
||||
A stable account of why an optimization result or incomplete calculation contains its displayed leave placements, expressed through placement provenance, planning objectives, time-off periods, and remaining balances. It reports every contribution of a placement while highlighting the first planning objective that made its position decisive, and describes the displayed plan rather than the calculation history.
|
||||
_Avoid_: calculation explanation
|
||||
@@ -144,6 +166,9 @@ A calculation stopped by user cancellation or another interruption before it com
|
||||
**Planning objective order**:
|
||||
The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize public-holiday week value; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. Complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A lower criterion never degrades a higher one.
|
||||
|
||||
**Earned leave**:
|
||||
The total quantity of earned leave entitlements generated by all applicable planning rules for a plan, counted before consumption and attributed to their generating rules. Only entitlements becoming available strictly after the calculation's Europe/Paris calendar date qualify, including those becoming available after the planning horizon.
|
||||
|
||||
**Earned leave entitlement**:
|
||||
A leave entitlement earned for each completed occurrence of an applicable planning rule after its generating time-off period ends, whether that occurrence is fulfilled by leave placements, declared non-working days, or both. It becomes available the following day, expires one year later, and may fund later compatible placements; only entitlements becoming available after the calculation's Europe/Paris calendar date are added to the plan.
|
||||
_Avoid_: reward, bonus days
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Plan Statistics Popup
|
||||
|
||||
Local development draft for [issue #3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [issue #29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), within [milestone %3: V1 stats](https://gitlab.nessar.fr/nessar/vac-optimizer/-/milestones/3). The behavior below consolidates the decisions confirmed during grill-with-docs; proposals and remaining presentation choices are identified separately.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The person planning leave can already see newly placed leave, non-working-day gain, and the longest work sequence in the calendar summary. They cannot assess average working stretches, the average and longest full breaks, or how much leave their rules earn in one view. They also need to spot undesirable full weeks in which they must work only one day and see exactly when those workdays occur.
|
||||
|
||||
The statistics page requested by #3 and the popup requested by #29 describe the same need. Their shared scope needs precise metric definitions so calendar dates, leave consumption, earned entitlements, and days remaining to work are not confused.
|
||||
|
||||
## Solution
|
||||
|
||||
Provide one statistics popup opened from the calendar. It summarizes the most recent calculated valid plan, including a valid plan retained by a cancelled or interrupted calculation, using that calculation's submitted inputs and Europe/Paris calendar date.
|
||||
|
||||
The popup presents nine metrics, a breakdown of earned leave by generating rule, and dated warnings for weeks with one workday. Its figures describe the calculated result and become available with the valid outcome, independently of calculation-trace delivery. Input edits and trace or counterfactual exploration leave those figures attached to the producing calculation until a new outcome arrives.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a person planning leave, I want one statistics popup from the calendar, so that I can assess the calculated plan in context. This single feature fulfils both #3 and #29.
|
||||
2. As a person planning leave, I want the following nine metrics with their units and meanings, so that I can distinguish leave consumption from calendar time off and days remaining to work.
|
||||
|
||||
| Metric | Agreed definition |
|
||||
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Newly placed leave days | Number of leave placement dates in the calculated plan. Declared non-working days are excluded from this consumption count. |
|
||||
| Non-working-day gain | Distinct weekend and public-holiday dates in planned time-off periods, including adjacent dates outside the horizon. A date contributes once even if it is both a weekend and a public holiday. |
|
||||
| Average work-sequence length | Arithmetic mean working-day length of all non-empty work sequences inside the horizon, including both horizon edges. Weekends and public holidays neither count nor split a sequence. |
|
||||
| Longest work-sequence length | Greatest working-day length of any work sequence inside the horizon, including both horizon edges. This retains the metric already requested by #28. |
|
||||
| Average time-off period length | Arithmetic mean calendar-day duration of all planned time-off periods, using their full spans, including adjacent non-working dates outside the horizon. |
|
||||
| Longest time-off period length | Greatest calendar-day duration among the same planned time-off periods. |
|
||||
| Earned leave | Gross quantity of earned leave entitlements generated by all applicable rules for this plan and becoming available strictly after its calculation date. Includes entitlements becoming available after the horizon and days subsequently consumed. |
|
||||
| Public holidays connected to a weekend | Number of distinct Monday-to-Friday public-holiday dates in planned time-off periods containing at least one Saturday or Sunday. Includes connections through placed or declared leave and adjacent holiday dates outside the horizon. |
|
||||
| Weeks with one workday | Number of Monday-to-Sunday weeks with exactly one working day remaining after leave placements and declared non-working days. All five Monday-to-Friday dates must lie inside the horizon. |
|
||||
|
||||
3. As a person planning leave, I want declared non-working days to contribute to time-off periods, interrupt work sequences, and remove days remaining to work, so that existing bookings are reflected without consuming a remaining balance again. This includes plans with declarations and no new placements.
|
||||
4. As a person planning leave, I want averages to reflect actual sequences and complete breaks, so that artificial zero-length gaps and leave-only counts do not distort them. Work sequences of 10, 5, and 15 working days average 10; Friday and Monday off in a holiday-free interval form one 4-calendar-day time-off period.
|
||||
5. As a person planning leave, I want earned leave grouped by its generating rule, using that calculation's rule names, so that I can see contributions from Samedi Malin and every other rule that earns leave. Repeated eligible occurrences contribute their generated quantities; the breakdown sums to the total.
|
||||
6. As a person planning leave, I want earned leave counted before consumption, so that a plan earning 3 days and using 2 still reports 3 earned days. Fully consumed earned entitlements must remain represented in the total and breakdown.
|
||||
7. As a person planning leave, I want the earned-leave cutoff fixed to the calculation's Europe/Paris date, so that historical earnings already included in reported balances are excluded. Availability on or before that date is excluded; availability afterward is included, even beyond the horizon. Pending input edits or a later calendar date do not reinterpret the existing result.
|
||||
8. As a person planning leave, I want weekday holidays connected through leave to count as well as directly adjacent holidays, so that a Tuesday public holiday joined to a weekend by Monday off is included. Saturday and Sunday public holidays are excluded from this metric. Naturally long weekends with no leave placement or declared non-working day are excluded; a qualifying planned time-off period must contain at least one such day.
|
||||
9. As a person planning leave, I want adjacent holidays beyond the horizon included when they belong to a qualifying time-off period, so that a Friday placement before a weekend and a Monday holiday outside the horizon still reports that holiday.
|
||||
10. As a person planning leave, I want weeks with one workday highlighted as undesirable patterns, so that I can find when they happen. When the count is positive, show the matching weeks and the exact date of each lone workday. Weeks with zero or two remaining workdays do not qualify, even if a week with two workdays contains separate one-day work sequences.
|
||||
11. As a person planning leave, I want partial workweeks at the horizon edges excluded from these warnings, so that clipped dates cannot create false one-workday weeks. A week whose Monday-to-Friday dates are all inside the horizon qualifies even if its weekend lies outside it.
|
||||
12. As a person planning leave, I want statistics for a valid retained partial plan to carry a prominent "Partial plan" label, so that I do not mistake unfinished optimization for a complete result. Invalid input, a planning conflict, and an incomplete calculation without a valid retained plan expose no plan statistics.
|
||||
13. As a person planning leave, I want the figures and rule names to remain attached to the calculated plan while I edit inputs or inspect historical trace steps or hypothetical alternatives, so that I see one coherent result. A new planning outcome replaces or clears the statistics; late replies from older calculations cannot overwrite them.
|
||||
14. As a person planning leave, I want statistics available when the valid plan is displayed even if its trace is pending or fails, so that optional diagnostic delivery does not block the summary.
|
||||
15. As a keyboard or mobile user, I want a readable popup with labelled controls, visible focus, keyboard dismissal, and focus restored on close, so that I can use it with the application's existing interaction and language settings.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
- **Agreed:** Extend calendar-result presentation with one popup. Its nine metrics describe the latest calculated valid plan or retained valid partial plan. Opening it does not run another optimization or mutate placements, balances, declarations, or rules.
|
||||
- **Agreed:** Use the producing calculation's outcome and immutable submitted horizon, balances, rules, declarations, and calculation date. Capture the Europe/Paris calculation date once and retain it with the calculation. Rule attribution uses generating rule identity and that run's rule names, including after a rule is renamed or deleted from pending inputs.
|
||||
- **Agreed:** Preserve the half-open planning horizon and inclusive selected last planning date. Work sequences and qualifying workweeks are measured within that horizon; time-off periods retain their full adjacent non-working spans. Support past and multi-year horizons under the existing calendar contract.
|
||||
- **Agreed:** Statistics are derived from the final or retained valid state of the producing calculation. Candidate evaluations, rejected branches, provisional earnings, and hypothetical alternative plans do not contribute. Trace or explanation readiness and failure are independent of statistics availability.
|
||||
- **Agreed:** Reuse the planner's working-day, French public-holiday, period, and entitlement definitions. Public holidays connected to weekends are a date count, distinct from the weighted public-holiday week value used in optimization. Weeks with one workday are full-workweek diagnostics, distinct from short work sequences.
|
||||
- **Integration proposal:** Keep metric derivation calculation-owned and presentation thin. Reuse existing outcome facts where sufficient; extend the public planning result or calculation metadata only for missing facts, notably the fixed calculation date and reliable gross earned quantities with generating-rule attribution, including fully consumed entitlements. Carry those facts with complete outcomes and retained partial plans through worker delivery and cancellation checkpoints. Do not reconstruct them from trace history or infer their identity by parsing balance IDs.
|
||||
- **Presentation proposal:** Reuse the existing Angular Material dialog, translation service, date formatting, and styling patterns. No new dialog, chart, or date dependency is needed. The exact derived-statistics record shape and division between planner-owned facts and presentation calculations remain implementation choices; no extra public interface is required solely for testing.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
Use behavior-level tests that assert returned planning facts and user-visible figures, dates, labels, and lifecycle behavior. Avoid tests coupled to aggregation loops, candidate ordering, private helpers, or dialog component structure.
|
||||
|
||||
- Extend the existing public planner and calculation-context tests for reliable earned metadata and metric derivation where exposed. Use fixed date-only horizons, controlled Europe/Paris calculation dates, and complete requests; do not depend on the real clock. Existing cases for merged periods, shared rules, earned-entitlement chains, immutable snapshots, and boundary dates provide prior art.
|
||||
- Cover work and time-off metrics with fractional averages, both horizon-edge work sequences, consecutive leave dates without an intervening work sequence, weekends and holidays that do not split work sequences, and declared-day-only periods. Verify work lengths in working days and break lengths in calendar days. Saved declarations outside the horizon must not inflate in-horizon work counts or weekly warnings.
|
||||
- Cover earned leave with 3 days earned and 2 consumed, a fully consumed entitlement absent from remaining balances, multiple generating rules, repeated occurrences, and distinct rules sharing placements. Verify acquisition before, on, and after the fixed calculation date, acquisition after the horizon, and the finalized availability date after the complete generating period. Use current rule names changed after submission to prove that the original attribution remains stable.
|
||||
- Cover the holiday count with direct Monday/Friday connections, a Tuesday holiday joined by Monday off, a weekday-only break that reaches no weekend, Saturday/Sunday holidays, a naturally long weekend without placed or declared leave, duplicate calendar-date contributions, and an adjacent holiday outside the horizon.
|
||||
- Cover one-workday warnings with four weekdays off and one remaining workday, zero-workday and two-workday weeks, separate one-day work sequences inside a two-workday week, partial horizon-edge weeks, a horizon including all weekdays but excluding the weekend, and weeks spanning month or year boundaries. Assert both the count and the displayed week and lone-workday dates.
|
||||
- Extend CalendarYear's existing planning-outcome tests for popup access, the nine rendered metrics, rule breakdown, warning rows, and the partial-plan label. Existing summary assertions, adjacent-year context dates, immutable submitted inputs, stale-outcome rejection, accessible comparison dialogs, and delayed or failed trace delivery provide prior art. Include replacement by an outcome without a valid plan so previous statistics cannot remain visible as current.
|
||||
- Extend existing worker transport and real cancellation tests for any new result facts: complete and retained partial outcomes must preserve those facts, and trace delay or failure must not remove them. Verify metadata matches the actual retained plan, rather than the latest evaluated candidate or an abandoned branch.
|
||||
- Check keyboard opening, dismissal, restored focus, meaningful labels and units, localized dates, and readable mobile layout using the existing UI testing patterns. Exercise a valid plan with no periods or no work sequences; keep its data state distinct from an outcome without a valid plan, and verify the chosen empty-value display once that presentation convention is adopted.
|
||||
- Run the repository's existing tests, lint, formatting, type checks, and production build when implementing the feature. No new test framework or speculative benchmark suite is required.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- A separate statistics page, saved plan history, or comparisons between separate planning results.
|
||||
- Changing the planning objective order or automatically moving leave to eliminate weeks with one workday. This feature makes those weeks visible.
|
||||
- Reconstructing historical earned leave already represented in reported balances, or treating weekend public holidays as additional weekday holiday gain.
|
||||
- New rule families, working calendars, accounts, backend synchronization, or employer integration.
|
||||
|
||||
## Further Notes
|
||||
|
||||
- Scope references: #3 and #29 describe the shared popup; #7 supplies the average work-sequence requirement; #8 supplies earned leave; #9 supplies public holidays connected to weekends; #28's longest work-sequence metric already exists. The discussion adds average and longest time-off period lengths and dated one-workday-week warnings. Existing planner and calculation-performance work in the milestone remains the baseline.
|
||||
- Domain definitions are maintained in [CONTEXT.md](../CONTEXT.md). Relevant architectural constraints are the [personal client-side planner](adr/0002-personal-client-side-planner-for-now.md), [separate calculation outcome and trace](adr/0004-stable-explanation-original-trace-counterfactual.md), [selected horizons and cancellation](adr/0006-user-selected-horizons-and-cancellable-calculations.md), and [declared days and reported balances](adr/0007-declared-days-and-reported-balances.md).
|
||||
- **Current integration gaps:** The inspected outcome exposes placements, periods, remaining balances, and several quality metrics, but no dedicated gross earned-leave summary or fixed calculation date. Remaining balances omit fully consumed entitlements. Declared-day support is in progress in the working tree: declarations are carried with outcomes, while the inspected period and work-sequence calculations still use placements alone. Coordinate with #13's finalized semantics before relying on those values; avoid a second conflicting calendar model in the popup.
|
||||
- **Proposed empty-value convention, not explicitly confirmed:** Counts show 0 when there are no qualifying items. An average with no samples, or longest time-off period length when no periods exist, shows "—". The existing longest work-sequence length remains 0 when there are no work sequences. Empty samples should remain distinguishable from a measured zero in the underlying data.
|
||||
- **Remaining presentation choices:** Exact decimal precision for averages, metric layout, and whether eligible rules with zero earnings receive breakdown rows were not decided. These do not change the agreed counting or date rules. Use existing presentation conventions and keep any selected defaults explicit during implementation.
|
||||
@@ -30,6 +30,8 @@
|
||||
@let dayTooltip = getDayTooltip(day);
|
||||
@let dateKey = getDateKey(day);
|
||||
@let status = getStatus(day);
|
||||
@let declaredType = getDeclaredType(dateKey);
|
||||
@let previewed = declarationPreview?.dates?.has(dateKey);
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-date]="dateKey"
|
||||
@@ -40,6 +42,9 @@
|
||||
[class.current]="dateKey === currentDate"
|
||||
[class.explained-period]="isInExplainedPeriod(day)"
|
||||
[class.context-only]="!isInHorizon(day)"
|
||||
[class.declared]="!!declaredType"
|
||||
[class.declaration-preview]="previewed"
|
||||
[class.declaration-erase]="previewed && !declaredType"
|
||||
[class.eligible]="status?.kind === 'eligible'"
|
||||
[class.ineligible]="status?.kind === 'ineligible'"
|
||||
[class.chosen]="status?.kind === 'chosen'"
|
||||
@@ -52,10 +57,13 @@
|
||||
[class.change-removed]="status?.change === 'removed'"
|
||||
[class.change-shared]="status?.change === 'shared'"
|
||||
[class.change-unchanged]="status?.change === 'unchanged'"
|
||||
[class]="selectedDates.getClasses(day)"
|
||||
[class]="getDayClasses(day)"
|
||||
class="day number"
|
||||
[disabled]="!isInHorizon(day)"
|
||||
[disabled]="isDateDisabled(day)"
|
||||
[attr.aria-pressed]="declarationEntry ? !!declaredType : null"
|
||||
[matTooltip]="dayTooltip"
|
||||
matTooltipTouchGestures="off"
|
||||
[matTooltipDisabled]="!!declarationPreview"
|
||||
[matTooltipShowDelay]="500"
|
||||
[attr.aria-label]="getDayAriaLabel(day, dayTooltip)"
|
||||
[attr.aria-current]="dateKey === currentDate ? 'date' : null"
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
}
|
||||
|
||||
&.number {
|
||||
height: auto;
|
||||
min-height: 2.75rem;
|
||||
background-color: var(--ui-card-bg);
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
@@ -83,6 +85,27 @@
|
||||
&.past {
|
||||
color: var(--past-color);
|
||||
}
|
||||
|
||||
&.declared {
|
||||
background-image: var(--declared-day-pattern);
|
||||
color: var(--number-color);
|
||||
}
|
||||
|
||||
&.declaration-preview {
|
||||
box-shadow: inset 0 0 0 2px var(--today-border-color);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.declaration-erase {
|
||||
box-shadow: inset 0 0 0 2px var(--ui-error-text);
|
||||
&::after {
|
||||
content: '−';
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0.2rem;
|
||||
color: var(--ui-error-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-marker {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { DateCacheService } from '../../services/date-cache.service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
||||
import type { LeaveType } from '../../services/leave-planner.service';
|
||||
|
||||
export interface CalendarDayStatus {
|
||||
kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined';
|
||||
@@ -23,6 +25,11 @@ export interface CalendarDayStatus {
|
||||
changeLabel?: string;
|
||||
}
|
||||
|
||||
export interface DeclarationPreview {
|
||||
dates: ReadonlySet<string>;
|
||||
type: LeaveType | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-calendar-month',
|
||||
imports: [MatTooltip],
|
||||
@@ -65,6 +72,40 @@ export class CalendarMonth implements OnInit {
|
||||
@Input()
|
||||
horizonEnd?: string;
|
||||
|
||||
@Input()
|
||||
declarationEntry = false;
|
||||
|
||||
@Input()
|
||||
declaredDays: Readonly<Record<string, LeaveType>> = {};
|
||||
|
||||
@Input()
|
||||
declarationPreview: DeclarationPreview | null = null;
|
||||
|
||||
getDeclaredType(date: string): LeaveType | undefined {
|
||||
const current = this.declaredDays[date];
|
||||
if (!this.declarationPreview?.dates.has(date)) return current;
|
||||
const selected = this.declarationPreview.type;
|
||||
return current === selected ? undefined : (selected ?? undefined);
|
||||
}
|
||||
|
||||
getDayClasses(day: Date): string {
|
||||
const classes = this.selectedDates.getClasses(day);
|
||||
return this.declarationPreview?.dates.has(this.getDateKey(day))
|
||||
? `${classes.replace(/\b(cp|rtt|other)\b/g, '')} ${this.getDeclaredType(this.getDateKey(day)) ?? ''}`
|
||||
: classes;
|
||||
}
|
||||
|
||||
isDateDisabled(day: Date): boolean {
|
||||
return (
|
||||
!this.isInHorizon(day) ||
|
||||
(this.declarationEntry && !isWorkingPlanningDate(this.getDateKey(day)))
|
||||
);
|
||||
}
|
||||
|
||||
declaredTypeLabel(type: LeaveType): string {
|
||||
return type === 'cp' ? 'CP' : this.translationService.getDayTypeLabel(type);
|
||||
}
|
||||
|
||||
@Input()
|
||||
currentDate: string | null = null;
|
||||
|
||||
@@ -197,9 +238,20 @@ export class CalendarMonth implements OnInit {
|
||||
}
|
||||
|
||||
getDayTooltip(day: Date): string | null {
|
||||
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
|
||||
const tooltips: string[] = this.selectedDates.datesSelected
|
||||
.filter((dateSelected) => dateSelected.isSelected(day) && dateSelected.type)
|
||||
.filter(
|
||||
(dateSelected) =>
|
||||
dateSelected.isSelected(day) &&
|
||||
dateSelected.type &&
|
||||
(!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)),
|
||||
)
|
||||
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
|
||||
const declaredType = this.getDeclaredType(this.getDateKey(day));
|
||||
if (declaredType)
|
||||
tooltips.push(
|
||||
`${this.tr().bookedDays.booked} (${this.declaredTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
|
||||
);
|
||||
const status = this.getStatus(day);
|
||||
if (status) {
|
||||
tooltips.push(status.label);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { CalendarYear } from './calendar-year';
|
||||
import { UserInput } from '../user-input/user-input';
|
||||
import {
|
||||
CalendarService,
|
||||
DayType,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
HistoricalEligibilityReason,
|
||||
LeavePlacement,
|
||||
PlanOutcome,
|
||||
PlanExplanation,
|
||||
PlanningCalculationContext,
|
||||
PlanningConflictOutcome,
|
||||
PlanningExplanation,
|
||||
@@ -405,6 +407,410 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear, UserInput],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calculate = vi
|
||||
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
|
||||
.mockReturnValue(calculationFor({ ...plan, placements: [] }));
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-26', '2024-05-10');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
input.toggleDeclaredDay('2024-04-26', 'other');
|
||||
input.toggleDeclaredDay('2024-04-30', 'cp');
|
||||
input.toggleDeclaredDay('2024-05-02', 'rtt');
|
||||
const controlsFixture = TestBed.createComponent(UserInput);
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const controls: HTMLElement = controlsFixture.nativeElement;
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
controls.querySelector<HTMLButtonElement>('button.declaration-entry-toggle')!.click();
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
const day = (date: string) =>
|
||||
root.querySelector<HTMLButtonElement>(`[data-date="${date}"]`)!;
|
||||
const pointer = (event: string, date: string) => {
|
||||
day(date).dispatchEvent(
|
||||
new PointerEvent(event, {
|
||||
bubbles: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
buttons: event === 'pointerup' ? 0 : 1,
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
}),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
};
|
||||
const saved = localStorage.getItem('declaredNonWorkingDays');
|
||||
pointer('pointerdown', '2024-04-29');
|
||||
pointer('pointermove', '2024-05-07');
|
||||
expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(true);
|
||||
expect(day('2024-05-02').classList.contains('cp')).toBe(true);
|
||||
expect(day('2024-05-02').classList.contains('rtt')).toBe(false);
|
||||
expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true);
|
||||
expect(day('2024-04-30').classList.contains('cp')).toBe(false);
|
||||
expect(day('2024-04-30').getAttribute('aria-pressed')).toBe('false');
|
||||
expect(day('2024-05-01').classList.contains('declaration-preview')).toBe(false);
|
||||
expect(day('2024-05-04').classList.contains('declaration-preview')).toBe(false);
|
||||
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(saved);
|
||||
pointer('pointermove', '2024-04-29');
|
||||
expect(day('2024-04-30').classList.contains('declaration-preview')).toBe(false);
|
||||
expect(day('2024-04-30').classList.contains('cp')).toBe(true);
|
||||
pointer('pointermove', '2024-05-03');
|
||||
expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true);
|
||||
expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(false);
|
||||
pointer('pointerup', '2024-05-03');
|
||||
// A browser click follows pointerup; it must not toggle the endpoint again.
|
||||
day('2024-05-03').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('.declaration-preview')).toBeNull();
|
||||
expect(root.querySelector('[role="status"]')?.textContent).toContain(
|
||||
'Jours déjà posés enregistrés : 3 (Congé Payé). Déclarations supprimées : 1.',
|
||||
);
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-04-26', type: 'other' },
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-05-02', type: 'cp' },
|
||||
{ date: '2024-05-03', type: 'cp' },
|
||||
]);
|
||||
expect(day('2024-05-02').textContent?.trim()).toBe('2');
|
||||
expect(day('2024-05-02').getAttribute('aria-label')).toContain('Déjà posé (CP)');
|
||||
|
||||
controls.querySelector<HTMLButtonElement>('[data-declaration-erase]')!.click();
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
pointer('pointerdown', '2024-05-03');
|
||||
pointer('pointermove', '2024-04-30');
|
||||
expect(day('2024-05-02').classList.contains('declaration-erase')).toBe(true);
|
||||
expect(day('2024-05-02').classList.contains('cp')).toBe(false);
|
||||
expect(input.declaredDaysSignal()).toHaveLength(4);
|
||||
pointer('pointerup', '2024-04-30');
|
||||
expect(root.querySelector('[role="status"]')?.textContent).toContain(
|
||||
'Déclarations supprimées : 2.',
|
||||
);
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-04-26', type: 'other' },
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
]);
|
||||
expect(input.vacationNumberSignal()).toEqual([]);
|
||||
expect(calculate).toHaveBeenCalledOnce();
|
||||
|
||||
const beforeCancellation = localStorage.getItem('declaredNonWorkingDays');
|
||||
pointer('pointerdown', '2024-04-29');
|
||||
pointer('pointermove', '2024-05-03');
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
pointer('pointerup', '2024-05-03');
|
||||
expect(root.querySelector('.declaration-preview')).toBeNull();
|
||||
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation);
|
||||
pointer('pointerdown', '2024-04-29');
|
||||
pointer('pointermove', '2024-05-03');
|
||||
pointer('pointercancel', '2024-05-03');
|
||||
pointer('pointerup', '2024-05-03');
|
||||
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation);
|
||||
|
||||
// The first animation frame may have the same timestamp as the pointer move.
|
||||
let scrollFrame: FrameRequestCallback | undefined;
|
||||
const requestFrame = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
scrollFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
const scroll = vi.spyOn(window, 'scrollBy').mockImplementation(() => undefined);
|
||||
pointer('pointerdown', '2024-04-29');
|
||||
day('2024-05-03').dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'mouse',
|
||||
buttons: 1,
|
||||
clientX: 200,
|
||||
clientY: window.innerHeight - 1,
|
||||
}),
|
||||
);
|
||||
scrollFrame!(0);
|
||||
expect(scroll.mock.calls[0][1]).toBeGreaterThan(0);
|
||||
pointer('pointercancel', '2024-05-03');
|
||||
requestFrame.mockRestore();
|
||||
scroll.mockRestore();
|
||||
|
||||
// Touch scrolling remains native, and a subsequent tap must still work after a drag.
|
||||
controls.querySelector<HTMLButtonElement>('[data-declared-type="other"]')!.click();
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
for (const event of ['pointerdown', 'pointermove', 'pointerup']) {
|
||||
day('2024-05-06').dispatchEvent(
|
||||
new PointerEvent(event, {
|
||||
bubbles: true,
|
||||
pointerId: 2,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(root.querySelector('.declaration-preview')).toBeNull();
|
||||
day('2024-05-06').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
|
||||
fixture.detectChanges();
|
||||
expect(day('2024-05-06').getAttribute('aria-label')).toContain('Déjà posé (Autre)');
|
||||
pointer('pointerdown', '2024-04-29');
|
||||
pointer('pointermove', '2024-05-03');
|
||||
controls.querySelector<HTMLButtonElement>('button.declaration-entry-toggle')!.click();
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
pointer('pointerup', '2024-05-03');
|
||||
expect(input.declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-04-26', type: 'other' },
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-05-06', type: 'other' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('edits declarations from configuration and restores the preceding result when switched off', async () => {
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem(
|
||||
'declaredNonWorkingDays',
|
||||
JSON.stringify([{ date: '2024-01-03', type: 'rtt' }]),
|
||||
);
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear, UserInput],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const savedPlan: PlanOutcome = {
|
||||
...plan,
|
||||
horizon: { start: '2024-01-02', end: '2024-01-08' },
|
||||
placements: [],
|
||||
declaredDays: [{ date: '2024-01-03', type: 'rtt' }],
|
||||
};
|
||||
const calculate = vi
|
||||
.spyOn(calendar, 'createPlanningCalculation')
|
||||
.mockReturnValue(calculationFor(savedPlan));
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-02', '2024-01-07');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
const controlsFixture = TestBed.createComponent(UserInput);
|
||||
controlsFixture.detectChanges();
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
const controls: HTMLElement = controlsFixture.nativeElement;
|
||||
expect(root.querySelector('.declaration-controls')).toBeNull();
|
||||
expect(controls.querySelector('.declaration-view-toggle')).toBeNull();
|
||||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
|
||||
const editButton = controls.querySelector<HTMLButtonElement>(
|
||||
'button.declaration-entry-toggle',
|
||||
)!;
|
||||
expect(editButton.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(root.querySelector('.calendar-legend li')?.textContent?.trim()).toBe('Déjà posé');
|
||||
const day = (date: string) =>
|
||||
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)!;
|
||||
expect(calculate.mock.calls[0][0]).toMatchObject({ declaredDays: savedPlan.declaredDays });
|
||||
expect(day('2024-01-03').getAttribute('aria-label')).toContain('Déjà posé');
|
||||
expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT');
|
||||
|
||||
settings.setPlanningDates('2024-01-08', '2024-02-02');
|
||||
activateWithKeyboard(editButton, ' ');
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
expect(editButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(3);
|
||||
expect(
|
||||
controls.querySelector('[data-declared-type="cp"]')?.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(controls.querySelector('[data-declared-type="any"]')).toBeNull();
|
||||
expect(day('2024-01-03').disabled).toBe(true);
|
||||
expect(day('2024-01-08').disabled).toBe(false);
|
||||
activateWithKeyboard(day('2024-01-08'), ' ');
|
||||
fixture.detectChanges();
|
||||
expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('true');
|
||||
expect(day('2024-01-08').getAttribute('aria-label')).toContain('Déjà posé');
|
||||
expect(day('2024-01-08').getAttribute('aria-label')).toContain('CP');
|
||||
activateWithKeyboard(day('2024-01-08'));
|
||||
fixture.detectChanges();
|
||||
expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('false');
|
||||
day('2024-01-08').click();
|
||||
activateWithKeyboard(
|
||||
controls.querySelector<HTMLButtonElement>('[data-declared-type="other"]')!,
|
||||
);
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
day('2024-01-08').click();
|
||||
fixture.detectChanges();
|
||||
expect(day('2024-01-08').getAttribute('aria-label')).toContain('Autre');
|
||||
expect(day('2024-01-13').disabled).toBe(true);
|
||||
expect(input.declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-01-03', type: 'rtt' },
|
||||
{ date: '2024-01-08', type: 'other' },
|
||||
]);
|
||||
expect(input.vacationNumberSignal()).toEqual([]);
|
||||
expect(calculate).toHaveBeenCalledOnce();
|
||||
activateWithKeyboard(editButton, ' ');
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
expect(editButton.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
|
||||
expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT');
|
||||
expect(day('2024-01-08').disabled).toBe(true);
|
||||
activateWithKeyboard(day('2024-01-03'));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('[role="status"]')?.textContent).toContain('Déjà posé');
|
||||
expect(root.querySelector('[role="status"]')?.textContent).toContain('solde');
|
||||
});
|
||||
|
||||
it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear, UserInput],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const initial: PlanOutcome = {
|
||||
...plan,
|
||||
horizon: { start: '2024-01-02', end: '2024-01-08' },
|
||||
placements: [{ ...plan.placements[0], date: '2024-01-04', ruleIds: ['linked'] }],
|
||||
declaredDays: [{ date: '2024-01-03', type: 'rtt' }],
|
||||
};
|
||||
const oldExplanation: PlanExplanation = {
|
||||
kind: 'PlanExplanation',
|
||||
placements: [
|
||||
{
|
||||
...explanation.placements[0],
|
||||
placement: initial.placements[0],
|
||||
ruleIds: ['linked'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const lateExplanation = deferred<PlanningExplanation | undefined>();
|
||||
const lateTrace = deferred<PlanningTrace | undefined>();
|
||||
const initialCalculation = {
|
||||
...calculationFor(initial, oldExplanation),
|
||||
explanation: lateExplanation.promise,
|
||||
trace: lateTrace.promise,
|
||||
};
|
||||
const rule = {
|
||||
id: 'linked',
|
||||
name: 'Original rule',
|
||||
type: 'time_window' as const,
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-07',
|
||||
placeDays: 1,
|
||||
placeType: 'cp' as const,
|
||||
};
|
||||
initialCalculation.snapshot.rules = [rule];
|
||||
const calculate = vi
|
||||
.spyOn(calendar, 'createPlanningCalculation')
|
||||
.mockReturnValueOnce(initialCalculation);
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
settings.setPlanningDates('2024-01-02', '2024-01-07');
|
||||
const input = TestBed.inject(UserInputService);
|
||||
const controlsFixture = TestBed.createComponent(UserInput);
|
||||
controlsFixture.detectChanges();
|
||||
const fixture = TestBed.createComponent(CalendarYear);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
const controls: HTMLElement = controlsFixture.nativeElement;
|
||||
const editButton = controls.querySelector<HTMLButtonElement>(
|
||||
'button.declaration-entry-toggle',
|
||||
)!;
|
||||
input.toggleDeclaredDay('2024-05-10', 'cp');
|
||||
settings.setPlanningDates('2024-05-06', '2024-05-12');
|
||||
editButton.click();
|
||||
controlsFixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
for (const date of ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12']) {
|
||||
const day = root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)!;
|
||||
expect(day.disabled).toBe(true);
|
||||
day.click();
|
||||
}
|
||||
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'cp' }]);
|
||||
const submitted: PlanOutcome = {
|
||||
...initial,
|
||||
horizon: { start: '2024-05-06', end: '2024-05-13' },
|
||||
declaredDays: [{ date: '2024-05-10', type: 'cp' }],
|
||||
};
|
||||
const pending = deferred<PlanningOutcome>();
|
||||
const calculation = calculationFor(submitted);
|
||||
calculation.snapshot.rules = [{ ...rule, name: 'Replacement rule' }];
|
||||
calculate.mockReturnValueOnce({
|
||||
...calculation,
|
||||
snapshot: { ...calculation.snapshot, ...submitted },
|
||||
outcome: pending.promise,
|
||||
});
|
||||
controls.querySelector<HTMLButtonElement>('.calculate-button')!.click();
|
||||
fixture.detectChanges();
|
||||
controlsFixture.detectChanges();
|
||||
expect(editButton.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
|
||||
input.toggleDeclaredDay('2024-05-10', 'other');
|
||||
settings.setPlanningDates('2024-06-03', '2024-06-07');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['planningOutcome']()).toBe(initial);
|
||||
expect(
|
||||
root.querySelector('button[data-date="2024-01-03"]')?.getAttribute('aria-label'),
|
||||
).toContain('RTT');
|
||||
expect(root.querySelector('button[data-date="2024-05-10"]')).toBeNull();
|
||||
expect(calculate.mock.calls[1][0]).toMatchObject({
|
||||
horizon: submitted.horizon,
|
||||
declaredDays: submitted.declaredDays,
|
||||
});
|
||||
const originalTrace: PlanningTrace = {
|
||||
status: 'ready',
|
||||
initialState: {
|
||||
horizon: initial.horizon,
|
||||
declaredDays: initial.declaredDays,
|
||||
balances: [],
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
phases: [],
|
||||
completedStages: initial.completedStages,
|
||||
};
|
||||
lateExplanation.resolve(oldExplanation);
|
||||
lateTrace.resolve(originalTrace);
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['planningTrace']()).toBe(originalTrace);
|
||||
expect(fixture.componentInstance['traceLoading']()).toBe(false);
|
||||
expect(fixture.componentInstance['traceResolved']()).toBe(true);
|
||||
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
|
||||
details.open = true;
|
||||
details.dispatchEvent(new Event('toggle'));
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('.leave-story')?.textContent).toContain('Original rule');
|
||||
expect(root.querySelector('.leave-story')?.textContent).not.toContain('Replacement rule');
|
||||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||||
await fixture.whenStable();
|
||||
expect(initialCalculation.analyzeCounterfactual).toHaveBeenCalled();
|
||||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||||
pending.resolve(submitted);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
root.querySelector('button[data-date="2024-05-10"]')?.getAttribute('aria-label'),
|
||||
).toContain('CP');
|
||||
expect(root.querySelector('button[data-date="2024-06-03"]')).toBeNull();
|
||||
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'other' }]);
|
||||
});
|
||||
|
||||
it('calibrates the wait through calendar rendering while a long trace remains pending', async () => {
|
||||
let now = 1_000;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||
|
||||
@@ -899,11 +899,19 @@
|
||||
</div>
|
||||
}
|
||||
<p class="calendar-help">
|
||||
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
|
||||
<mat-icon aria-hidden="true">touch_app</mat-icon
|
||||
>{{ declarationEntry() ? tr().bookedDays.help : tr().planning.calendarHelp }}
|
||||
</p>
|
||||
@if (declarationEntry() && !calendarHorizon()) {
|
||||
<p role="alert">{{ tr().settings.invalidHorizon }}</p>
|
||||
}
|
||||
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
|
||||
<strong>{{ tr().planning.legend }}</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<span class="legend-swatch declared" aria-hidden="true"></span
|
||||
>{{ tr().bookedDays.booked }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-swatch weekend" aria-hidden="true"></span
|
||||
>{{ tr().dayTypes.weekend }}
|
||||
@@ -995,7 +1003,13 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="month-grid">
|
||||
<div
|
||||
#monthGrid
|
||||
class="month-grid"
|
||||
[class.declaration-entry]="declarationEntry()"
|
||||
(pointerdown)="startDeclarationRange($event)"
|
||||
(click)="selectDeclaredDate($event)"
|
||||
>
|
||||
@for (month of months(); track month.year + '-' + month.monthIndex) {
|
||||
<mat-card class="month-card">
|
||||
<mat-card-content>
|
||||
@@ -1003,12 +1017,15 @@
|
||||
[monthIndex]="month.monthIndex"
|
||||
[year]="month.year"
|
||||
[selectedDates]="calendarSelectedDates()"
|
||||
[horizonStart]="planningHorizon()?.start"
|
||||
[horizonEnd]="planningHorizon()?.end"
|
||||
[currentDate]="currentPlacementDate()"
|
||||
[explainedPeriod]="explainedPeriod()"
|
||||
[dateStatuses]="traceReplay() ? dateStatuses() : {}"
|
||||
(dateSelected)="selectCalendarDate($event)"
|
||||
[horizonStart]="calendarHorizon()?.start"
|
||||
[horizonEnd]="calendarHorizon()?.end"
|
||||
[declarationEntry]="declarationEntry()"
|
||||
[declaredDays]="calendarDeclaredDays()"
|
||||
[declarationPreview]="declarationPreview()"
|
||||
[currentDate]="declarationEntry() ? null : currentPlacementDate()"
|
||||
[explainedPeriod]="declarationEntry() ? undefined : explainedPeriod()"
|
||||
[dateStatuses]="!declarationEntry() && traceReplay() ? dateStatuses() : {}"
|
||||
(dateSelected)="!declarationEntry() && selectCalendarDate($event)"
|
||||
></app-calendar-month>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@@ -420,6 +420,11 @@ output {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
&.declared {
|
||||
background-image: var(--declared-day-pattern);
|
||||
border: 1px solid var(--ui-border);
|
||||
}
|
||||
&.weekend {
|
||||
background: var(--weekend-bg);
|
||||
}
|
||||
@@ -455,6 +460,9 @@ output {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 1rem;
|
||||
&.declaration-entry {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
.month-card {
|
||||
min-width: 0;
|
||||
|
||||
@@ -23,7 +23,13 @@ import { MatButton } from '@angular/material/button';
|
||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
||||
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
|
||||
import {
|
||||
CalendarMonth,
|
||||
CalendarDayStatus,
|
||||
DeclarationPreview,
|
||||
} from '../calendar-month/calendar-month';
|
||||
import { addPlanningDays } from '../../services/planning-date';
|
||||
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
||||
import {
|
||||
CalendarService,
|
||||
DayType,
|
||||
@@ -33,12 +39,15 @@ import {
|
||||
} from '../../services/calendar-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { UserInputService } from '../../services/user-input-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||
import {
|
||||
applyReplayDeltas,
|
||||
CounterfactualAnalysis,
|
||||
CounterfactualSelection,
|
||||
LeavePlacement,
|
||||
LeaveType,
|
||||
DeclaredNonWorkingDay,
|
||||
PlanExplanation,
|
||||
PlanOutcome,
|
||||
PlanningCalculationContext,
|
||||
@@ -80,6 +89,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
|
||||
export class CalendarYear {
|
||||
private readonly userInput = inject(UserInputService);
|
||||
private readonly calendarService = inject(CalendarService);
|
||||
private readonly settings = inject(CalendarSettingsService);
|
||||
private readonly duration = inject(PlanningDurationService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
@@ -124,11 +134,48 @@ export class CalendarYear {
|
||||
protected readonly historicalEligibilityError = signal<string | null>(null);
|
||||
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
|
||||
protected readonly selectedDates = signal(new SelectedDates());
|
||||
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
|
||||
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
|
||||
private readonly declarationDrag = signal<{
|
||||
pointerId: number;
|
||||
start: string;
|
||||
end: string;
|
||||
} | null>(null);
|
||||
private suppressDeclarationClick = false;
|
||||
private declarationEvents?: AbortController;
|
||||
private declarationScrollFrame?: number;
|
||||
private declarationPointer?: PointerEvent;
|
||||
protected readonly declarationPreview = computed<DeclarationPreview | null>(() => {
|
||||
const drag = this.declarationDrag();
|
||||
const horizon = this.calendarHorizon();
|
||||
if (!drag || !horizon || !this.declarationEntry()) return null;
|
||||
const dates = new Set<string>();
|
||||
const start = drag.start < drag.end ? drag.start : drag.end;
|
||||
const end = drag.start > drag.end ? drag.start : drag.end;
|
||||
for (let date = start; date <= end; date = addPlanningDays(date, 1)) {
|
||||
if (date >= horizon.start && date < horizon.end && isWorkingPlanningDate(date))
|
||||
dates.add(date);
|
||||
}
|
||||
return { dates, type: this.userInput.declaredTypeSignal() };
|
||||
});
|
||||
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
|
||||
protected readonly calendarHorizon = computed(() =>
|
||||
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
|
||||
);
|
||||
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
|
||||
const days = this.declarationEntry()
|
||||
? this.userInput.declaredDaysSignal()
|
||||
: (this.traceState()?.declaredDays ??
|
||||
this.displayedPlan()?.declaredDays ??
|
||||
this.resultDeclaredDays());
|
||||
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
|
||||
});
|
||||
|
||||
private calculationVersion = 0;
|
||||
private counterfactualVersion = 0;
|
||||
private historicalEligibilityVersion = 0;
|
||||
private activeCalculation: PlanningCalculationContext | null = null;
|
||||
private runningCalculation: PlanningCalculationContext | null = null;
|
||||
private readonly traceDecisionIndices = new WeakMap<
|
||||
PlanningTrace,
|
||||
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
|
||||
@@ -255,10 +302,21 @@ export class CalendarYear {
|
||||
});
|
||||
|
||||
protected readonly calendarSelectedDates = computed(() => {
|
||||
if (this.declarationEntry()) {
|
||||
const horizon = this.calendarHorizon();
|
||||
return horizon
|
||||
? this.calendarService.selectedDatesForPlan({
|
||||
placements: [],
|
||||
horizon,
|
||||
declaredDays: this.userInput.declaredDaysSignal(),
|
||||
})
|
||||
: new SelectedDates();
|
||||
}
|
||||
const state = this.traceState();
|
||||
return state
|
||||
? this.calendarService.selectedDatesForPlan({
|
||||
placements: [...state.placements],
|
||||
declaredDays: state.declaredDays,
|
||||
horizon: { ...state.horizon },
|
||||
})
|
||||
: this.selectedDates();
|
||||
@@ -421,12 +479,14 @@ export class CalendarYear {
|
||||
});
|
||||
|
||||
protected readonly months = computed(() => {
|
||||
const horizon = this.planningHorizon();
|
||||
const horizon = this.calendarHorizon();
|
||||
if (!horizon) return [];
|
||||
let first = new Date(`${horizon.start}T00:00:00`);
|
||||
let last = new Date(`${horizon.end}T00:00:00`);
|
||||
last.setDate(last.getDate() - 1);
|
||||
for (const period of this.displayedPlan()?.timeOffPeriods ?? []) {
|
||||
for (const period of this.declarationEntry()
|
||||
? []
|
||||
: (this.displayedPlan()?.timeOffPeriods ?? [])) {
|
||||
for (const date of period.nonWorkingDates) {
|
||||
const contextDate = new Date(`${date}T00:00:00`);
|
||||
if (date < horizon.start && contextDate < first) first = contextDate;
|
||||
@@ -445,7 +505,23 @@ export class CalendarYear {
|
||||
|
||||
constructor() {
|
||||
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
|
||||
this.destroyRef.onDestroy(() => this.clearCounterfactual());
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearCounterfactual();
|
||||
this.cancelDeclarationRange();
|
||||
});
|
||||
effect(() => {
|
||||
this.declarationEntry();
|
||||
this.settings.planningHorizon();
|
||||
this.userInput.declaredTypeSignal();
|
||||
untracked(() => this.cancelDeclarationRange());
|
||||
});
|
||||
effect(() => {
|
||||
if (this.declarationEntry())
|
||||
untracked(() => {
|
||||
this.showFinalPlan();
|
||||
this.liveMessage.set(this.tr().bookedDays.enter);
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
this.userInput.computeTriggerSignal();
|
||||
untracked(() => this.startCalculation());
|
||||
@@ -458,8 +534,8 @@ export class CalendarYear {
|
||||
const request = this.duration.request();
|
||||
if (!request) return;
|
||||
const { horizon, rules: currentRules } = request;
|
||||
this.planningHorizon.set(horizon);
|
||||
this.resetCalculationState(horizon);
|
||||
this.declarationEntry.set(false);
|
||||
if (!this.planningHorizon()) this.planningHorizon.set(horizon);
|
||||
const version = ++this.calculationVersion;
|
||||
this.isLoading.set(true);
|
||||
this.isCancelling.set(false);
|
||||
@@ -483,19 +559,27 @@ export class CalendarYear {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.resetCalculationState(horizon);
|
||||
this.planningHorizon.set(horizon);
|
||||
this.failCalculation(error, version);
|
||||
return;
|
||||
}
|
||||
this.activeCalculation = calculation;
|
||||
this.loadArtifacts(calculation, version);
|
||||
this.runningCalculation = calculation;
|
||||
void calculation.outcome
|
||||
.then((outcome) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.resetCalculationState(horizon);
|
||||
this.activeCalculation = calculation;
|
||||
this.planningHorizon.set(horizon);
|
||||
this.applyOutcome(outcome);
|
||||
this.loadArtifacts(calculation);
|
||||
completed = outcome.kind === 'Plan' && !this.isCancelling();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.resetCalculationState(horizon);
|
||||
this.activeCalculation = calculation;
|
||||
this.planningHorizon.set(horizon);
|
||||
this.applyOutcome({
|
||||
kind: 'PlanningIncomplete',
|
||||
completedStages: [],
|
||||
@@ -509,9 +593,11 @@ export class CalendarYear {
|
||||
},
|
||||
],
|
||||
});
|
||||
this.loadArtifacts(calculation);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
this.runningCalculation = null;
|
||||
this.loadingProgress.set(100);
|
||||
this.isLoading.set(false);
|
||||
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
||||
@@ -531,10 +617,10 @@ export class CalendarYear {
|
||||
}
|
||||
|
||||
protected cancelCalculation(): void {
|
||||
if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return;
|
||||
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
|
||||
this.isCancelling.set(true);
|
||||
this.liveMessage.set(this.tr().planning.cancelling);
|
||||
this.activeCalculation.cancel();
|
||||
this.runningCalculation.cancel();
|
||||
}
|
||||
|
||||
private failCalculation(error: unknown, version: number): void {
|
||||
@@ -574,10 +660,10 @@ export class CalendarYear {
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
private loadArtifacts(calculation: PlanningCalculationContext, version: number): void {
|
||||
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
||||
void calculation.explanation
|
||||
.then((explanation) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
if (calculation !== this.activeCalculation) return;
|
||||
this.planningExplanation.set(explanation ?? null);
|
||||
if (explanation?.kind === 'PlanExplanation') {
|
||||
this.selectPlacement(0, false);
|
||||
@@ -587,7 +673,7 @@ export class CalendarYear {
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
if (calculation !== this.activeCalculation) return;
|
||||
const reason = {
|
||||
code: 'explanation-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -597,10 +683,11 @@ export class CalendarYear {
|
||||
});
|
||||
void calculation.explanationError
|
||||
.then((reason) => {
|
||||
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
|
||||
if (calculation === this.activeCalculation)
|
||||
this.explanationError.set(reason ?? null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
if (calculation !== this.activeCalculation) return;
|
||||
const reason = {
|
||||
code: 'explanation-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -610,7 +697,7 @@ export class CalendarYear {
|
||||
});
|
||||
void calculation.trace
|
||||
.then((trace) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
if (calculation !== this.activeCalculation) return;
|
||||
this.planningTrace.set(trace ?? null);
|
||||
this.traceResolved.set(true);
|
||||
this.liveMessage.set(
|
||||
@@ -620,11 +707,14 @@ export class CalendarYear {
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (version !== this.calculationVersion) return;
|
||||
if (calculation !== this.activeCalculation) return;
|
||||
this.planningTrace.set({
|
||||
status: 'failed',
|
||||
initialState: {
|
||||
horizon: calculation.snapshot.horizon,
|
||||
...(calculation.snapshot.declaredDays
|
||||
? { declaredDays: calculation.snapshot.declaredDays }
|
||||
: {}),
|
||||
balances: [],
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
@@ -640,7 +730,7 @@ export class CalendarYear {
|
||||
this.liveMessage.set(this.tr().planning.traceUnavailable);
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === this.calculationVersion) this.traceLoading.set(false);
|
||||
if (calculation === this.activeCalculation) this.traceLoading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -648,6 +738,9 @@ export class CalendarYear {
|
||||
this.planningOutcome.set(outcome);
|
||||
this.liveMessage.set(this.outcomeLabel(outcome));
|
||||
const plan = this.displayedPlan();
|
||||
this.resultDeclaredDays.set(
|
||||
plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
|
||||
);
|
||||
if (plan) {
|
||||
this.planningHorizon.set(plan.horizon);
|
||||
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
|
||||
@@ -722,7 +815,186 @@ export class CalendarYear {
|
||||
if (target !== current) this.selectPlacement(target);
|
||||
}
|
||||
|
||||
private declarationButton(target: EventTarget | null): HTMLButtonElement | null {
|
||||
const button =
|
||||
target instanceof Element ? target.closest<HTMLButtonElement>('[data-date]') : null;
|
||||
return button && this.monthGrid()?.nativeElement.contains(button) ? button : null;
|
||||
}
|
||||
|
||||
protected startDeclarationRange(event: PointerEvent): void {
|
||||
if (!this.declarationEntry() || event.button !== 0 || !event.isPrimary) return;
|
||||
this.suppressDeclarationClick = false;
|
||||
// Keep native touch scrolling and single-day taps available on the calendar.
|
||||
if (event.pointerType === 'touch') return;
|
||||
const button = this.declarationButton(event.target);
|
||||
const date = button?.dataset['date'];
|
||||
if (!date || button?.disabled) return;
|
||||
this.declarationDrag.set({
|
||||
pointerId: event.pointerId,
|
||||
start: date,
|
||||
end: date,
|
||||
});
|
||||
const grid = this.monthGrid()!.nativeElement;
|
||||
this.declarationEvents = new AbortController();
|
||||
const options = { signal: this.declarationEvents.signal };
|
||||
grid.ownerDocument.addEventListener(
|
||||
'pointermove',
|
||||
(e) => this.moveDeclarationRange(e),
|
||||
options,
|
||||
);
|
||||
grid.ownerDocument.addEventListener(
|
||||
'pointerup',
|
||||
(e) => this.finishDeclarationRange(e),
|
||||
options,
|
||||
);
|
||||
grid.ownerDocument.addEventListener(
|
||||
'pointercancel',
|
||||
(e) => this.cancelDeclarationRange(e),
|
||||
options,
|
||||
);
|
||||
grid.ownerDocument.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (e.key === 'Escape') this.cancelDeclarationRange();
|
||||
},
|
||||
options,
|
||||
);
|
||||
window.addEventListener('blur', () => this.cancelDeclarationRange(), options);
|
||||
grid.addEventListener('lostpointercapture', (e) => this.cancelDeclarationRange(e), options);
|
||||
grid.setPointerCapture?.(event.pointerId);
|
||||
button?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
protected moveDeclarationRange(event: PointerEvent): void {
|
||||
const drag = this.declarationDrag();
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
if (!(event.buttons & 1)) {
|
||||
this.cancelDeclarationRange();
|
||||
return;
|
||||
}
|
||||
this.declarationPointer = event;
|
||||
this.updateDeclarationEndpoint(event);
|
||||
this.scrollDeclarationRange();
|
||||
}
|
||||
|
||||
private updateDeclarationEndpoint(event: PointerEvent): void {
|
||||
const drag = this.declarationDrag();
|
||||
if (!drag) return;
|
||||
const grid = this.monthGrid()?.nativeElement;
|
||||
const target =
|
||||
grid?.ownerDocument.elementFromPoint?.(event.clientX, event.clientY) ?? event.target;
|
||||
const date = this.declarationButton(target)?.dataset['date'];
|
||||
if (date && date !== drag.end) this.declarationDrag.set({ ...drag, end: date });
|
||||
}
|
||||
|
||||
private scrollDeclarationRange(): void {
|
||||
if (this.declarationScrollFrame !== undefined) return;
|
||||
let lastFrame = 0;
|
||||
const scroll = (now: number) => {
|
||||
this.declarationScrollFrame = undefined;
|
||||
const pointer = this.declarationPointer;
|
||||
if (!pointer || !this.declarationDrag()) return;
|
||||
const direction =
|
||||
pointer.clientY < 48 ? -1 : pointer.clientY > window.innerHeight - 48 ? 1 : 0;
|
||||
if (!direction || pointer.clientX < 0 || pointer.clientX > window.innerWidth) return;
|
||||
const before = window.scrollY;
|
||||
window.scrollBy(0, direction * (lastFrame ? Math.min(now - lastFrame, 32) : 16) * 0.5);
|
||||
lastFrame = now;
|
||||
this.updateDeclarationEndpoint(pointer);
|
||||
if (window.scrollY !== before)
|
||||
this.declarationScrollFrame = requestAnimationFrame(scroll);
|
||||
};
|
||||
this.declarationScrollFrame = requestAnimationFrame(scroll);
|
||||
}
|
||||
|
||||
protected finishDeclarationRange(event: PointerEvent): void {
|
||||
const drag = this.declarationDrag();
|
||||
if (!drag || event.pointerId !== drag.pointerId || event.button !== 0) return;
|
||||
this.updateDeclarationEndpoint(event);
|
||||
const preview = this.declarationPreview();
|
||||
if (!preview) return;
|
||||
const dates = [...preview.dates];
|
||||
const previous = this.calendarDeclaredDays();
|
||||
this.userInput.toggleDeclaredDays(dates, preview.type);
|
||||
const next = this.calendarDeclaredDays();
|
||||
const added = dates.filter((date) => next[date]).length;
|
||||
const removed = dates.filter((date) => previous[date] && !next[date]).length;
|
||||
const messages: string[] = [];
|
||||
if (added && preview.type)
|
||||
messages.push(
|
||||
this.tr()
|
||||
.bookedDays.rangeSaved.replace('{count}', String(added))
|
||||
.replace('{type}', this.translationService.getDayTypeLabel(preview.type)),
|
||||
);
|
||||
if (removed || !added)
|
||||
messages.push(this.tr().bookedDays.rangeRemoved.replace('{count}', String(removed)));
|
||||
this.liveMessage.set(messages.join(' '));
|
||||
this.cancelDeclarationRange();
|
||||
this.animateDeclaration(preview.dates);
|
||||
}
|
||||
|
||||
protected cancelDeclarationRange(event?: PointerEvent): void {
|
||||
const drag = this.declarationDrag();
|
||||
if (!drag || (event && event.pointerId !== drag.pointerId)) return;
|
||||
this.declarationDrag.set(null);
|
||||
this.suppressDeclarationClick = true;
|
||||
this.declarationEvents?.abort();
|
||||
this.declarationEvents = undefined;
|
||||
if (this.declarationScrollFrame !== undefined)
|
||||
cancelAnimationFrame(this.declarationScrollFrame);
|
||||
this.declarationScrollFrame = undefined;
|
||||
this.declarationPointer = undefined;
|
||||
const grid = this.monthGrid()?.nativeElement;
|
||||
if (grid?.hasPointerCapture?.(drag.pointerId)) grid.releasePointerCapture(drag.pointerId);
|
||||
}
|
||||
|
||||
protected selectDeclaredDate(event: MouseEvent): void {
|
||||
if (!this.declarationEntry() || (event.detail > 0 && this.suppressDeclarationClick)) return;
|
||||
const button = this.declarationButton(event.target);
|
||||
const date = button?.dataset['date'];
|
||||
if (date && !button?.disabled) {
|
||||
this.selectCalendarDate(date);
|
||||
this.animateDeclaration(new Set([date]));
|
||||
}
|
||||
}
|
||||
|
||||
private animateDeclaration(dates: ReadonlySet<string>): void {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
for (const button of this.monthGrid()?.nativeElement.querySelectorAll<HTMLElement>(
|
||||
'[data-date]',
|
||||
) ?? []) {
|
||||
if (!dates.has(button.dataset['date']!)) continue;
|
||||
button.animate?.(
|
||||
[
|
||||
{ transform: 'scale(1.025, 0.97)' },
|
||||
{ transform: 'scale(0.99, 1.015)' },
|
||||
{ transform: 'scale(1)' },
|
||||
],
|
||||
{ duration: 240, easing: 'ease-out' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected selectCalendarDate(date: string): void {
|
||||
if (this.declarationEntry()) {
|
||||
const horizon = this.calendarHorizon();
|
||||
if (!horizon || date < horizon.start || date >= horizon.end) return;
|
||||
const selectedType = this.userInput.declaredTypeSignal();
|
||||
if (selectedType) this.userInput.toggleDeclaredDay(date, selectedType);
|
||||
else this.userInput.toggleDeclaredDays([date], null);
|
||||
const type = this.calendarDeclaredDays()[date];
|
||||
this.liveMessage.set(
|
||||
`${this.displayDate(date)}. ${type ? `${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(type)}). ${this.tr().bookedDays.fixed}` : this.tr().bookedDays.removed}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const declaredType = this.calendarDeclaredDays()[date];
|
||||
if (declaredType && !this.traceReplay() && !this.counterfactual()) {
|
||||
this.liveMessage.set(
|
||||
`${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.counterfactual()?.results.some((result) => result.date === date)) {
|
||||
this.invalidComparisonDate.set(false);
|
||||
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
|
||||
@@ -1035,6 +1307,9 @@ export class CalendarYear {
|
||||
};
|
||||
const snapshot = (): PlanningTraceState => ({
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
...(trace.initialState.declaredDays
|
||||
? { declaredDays: trace.initialState.declaredDays }
|
||||
: {}),
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements: [...state.placements.values()].map((placement) => ({
|
||||
...placement,
|
||||
|
||||
@@ -21,6 +21,50 @@
|
||||
<mat-icon aria-hidden="true">add</mat-icon>
|
||||
{{ tr().userInput.addVacationDay }}
|
||||
</button>
|
||||
<div class="declaration-controls" [class.is-active]="declarationEntry()">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="declaration-entry-toggle"
|
||||
[attr.aria-pressed]="declarationEntry()"
|
||||
(click)="declarationEntry.set(!declarationEntry())"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
declarationEntry() ? 'check_circle' : 'edit_calendar'
|
||||
}}</mat-icon>
|
||||
{{ tr().bookedDays.enter }}
|
||||
</button>
|
||||
@if (declarationEntry()) {
|
||||
<div class="declaration-types" role="group" [attr.aria-label]="tr().userInput.type">
|
||||
@for (type of declaredTypes; track type) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[attr.data-declared-type]="type"
|
||||
[attr.aria-pressed]="declaredType() === type"
|
||||
(click)="declaredType.set(type)"
|
||||
>
|
||||
<mat-icon class="declaration-type-check" aria-hidden="true"
|
||||
>check</mat-icon
|
||||
>
|
||||
{{ type === 'cp' ? 'CP' : translationService.getDayTypeLabel(type) }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-declaration-erase
|
||||
[attr.aria-pressed]="declaredType() === null"
|
||||
(click)="declaredType.set(null)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
declaredType() === null ? 'check' : 'backspace'
|
||||
}}</mat-icon>
|
||||
{{ tr().bookedDays.erase }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-rules-panel></app-rules-panel>
|
||||
|
||||
@@ -37,6 +37,75 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.declaration-controls {
|
||||
display: grid;
|
||||
container: declaration-controls / inline-size;
|
||||
grid-column: 1 / -1;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: var(--ui-radius-sm);
|
||||
background: var(--ui-panel-bg);
|
||||
|
||||
&.is-active {
|
||||
border-color: var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.declaration-entry-toggle {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: calc(var(--ui-radius-sm) - 1px);
|
||||
text-align: start;
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
color 150ms ease-out;
|
||||
|
||||
&[aria-pressed='true'] {
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
}
|
||||
|
||||
.declaration-types {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
|
||||
button {
|
||||
min-height: 2.75rem;
|
||||
height: auto;
|
||||
padding: 0.5rem;
|
||||
border-radius: calc(var(--ui-radius-sm) - 1px);
|
||||
background-color: var(--ui-card-bg);
|
||||
color: var(--number-color);
|
||||
line-height: 1.5;
|
||||
|
||||
&[data-declared-type='cp'] {
|
||||
background-color: var(--cp-bg);
|
||||
}
|
||||
&[data-declared-type='rtt'] {
|
||||
background-color: var(--rtt-bg);
|
||||
}
|
||||
&[data-declared-type='other'] {
|
||||
background-color: var(--other-bg);
|
||||
}
|
||||
&[aria-pressed='true'] {
|
||||
border-color: currentColor;
|
||||
box-shadow: inset 0 0 0 1px currentColor;
|
||||
}
|
||||
&[data-declared-type][aria-pressed='true'] {
|
||||
background-image: var(--declared-day-pattern);
|
||||
}
|
||||
&[aria-pressed='false'] .declaration-type-check {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.day-input-wrapper {
|
||||
animation: slide-fade-in 200ms ease-out;
|
||||
|
||||
@@ -131,6 +200,18 @@ form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@container declaration-controls (min-width: 18rem) {
|
||||
.declaration-types {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container declaration-controls (min-width: 34rem) {
|
||||
.declaration-types {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container user-input (min-width: 40rem) {
|
||||
.listOfDays {
|
||||
display: grid;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { DayInput } from '../userInput/day-input/day-input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { VacDay } from '../../services/calendar-service';
|
||||
import type { LeaveType } from '../../services/leave-planner.service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||
@@ -28,6 +29,9 @@ import { PlanningDurationService } from '../../services/planning-duration.servic
|
||||
})
|
||||
export class UserInput {
|
||||
private userInputService: UserInputService = inject(UserInputService);
|
||||
protected readonly declarationEntry = this.userInputService.declarationEntrySignal;
|
||||
protected readonly declaredType = this.userInputService.declaredTypeSignal;
|
||||
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
|
||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||
protected readonly duration = inject(PlanningDurationService);
|
||||
protected readonly translationService = inject(TranslationService);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
import Holidays, { HolidaysTypes } from 'date-holidays';
|
||||
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
|
||||
import dayjs from 'dayjs';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||
@@ -18,12 +18,6 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
|
||||
const lang = 'en';
|
||||
|
||||
const allTypesOptions: HolidaysTypes.Options = {
|
||||
languages: [lang],
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -39,7 +33,7 @@ export class CalendarService {
|
||||
}
|
||||
|
||||
selectedDatesForPlan(
|
||||
plan: Pick<PlanOutcome, 'placements'> & {
|
||||
plan: Pick<PlanOutcome, 'placements' | 'declaredDays'> & {
|
||||
horizon?: { start: string; end: string };
|
||||
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
|
||||
},
|
||||
@@ -55,7 +49,14 @@ export class CalendarService {
|
||||
const selected = projectionHorizon
|
||||
? this.getSelectedDatesForHorizon(projectionHorizon)
|
||||
: this.getSelectedDatesForYear();
|
||||
for (const placement of plan.placements) {
|
||||
for (const placement of [
|
||||
...plan.placements,
|
||||
...(plan.declaredDays ?? []).filter(
|
||||
(day) =>
|
||||
!plan.horizon ||
|
||||
(day.date >= plan.horizon.start && day.date < plan.horizon.end),
|
||||
),
|
||||
]) {
|
||||
selected.push(
|
||||
new SelectedDate(
|
||||
dayTypeForLeaveType[placement.type],
|
||||
@@ -69,8 +70,6 @@ export class CalendarService {
|
||||
return selected;
|
||||
}
|
||||
|
||||
private readonly holidaysByYear = new Map<number, number[]>();
|
||||
|
||||
// Get selected dates from current date to the next year
|
||||
getSelectedDatesFromNow(): SelectedDates {
|
||||
const _holidays = this.getHolidays();
|
||||
@@ -156,21 +155,8 @@ export class CalendarService {
|
||||
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
|
||||
const dates: Date[] = [];
|
||||
for (let year = startYear; year <= lastYear; year++) {
|
||||
let timestamps = this.holidaysByYear.get(year);
|
||||
if (!timestamps) {
|
||||
const calendar = new Holidays('FR', allTypesOptions);
|
||||
timestamps = calendar
|
||||
.getHolidays(year, lang)
|
||||
.filter((holiday) => holiday.type === 'public')
|
||||
.map((holiday) => {
|
||||
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
||||
return (
|
||||
dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date)
|
||||
).getTime();
|
||||
});
|
||||
this.holidaysByYear.set(year, timestamps);
|
||||
}
|
||||
dates.push(...timestamps.map((timestamp) => new Date(timestamp)));
|
||||
for (const date of frenchPublicHolidaysForYear(year))
|
||||
dates.push(new Date(`${date}T00:00:00`));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Holidays from 'date-holidays';
|
||||
import { isPlanningDate } from './planning-date';
|
||||
import type { LeaveType } from './leave-planner.core';
|
||||
|
||||
export interface DeclaredNonWorkingDay {
|
||||
date: string;
|
||||
type: LeaveType;
|
||||
}
|
||||
|
||||
const holidaysByYear = new Map<number, Set<string>>();
|
||||
|
||||
export function frenchPublicHolidaysForYear(year: number): ReadonlySet<string> {
|
||||
let holidays = holidaysByYear.get(year);
|
||||
if (!holidays) {
|
||||
holidays = new Set(
|
||||
new Holidays('FR', { languages: ['en'] })
|
||||
.getHolidays(year, 'en')
|
||||
.filter((holiday) => holiday.type === 'public')
|
||||
.map((holiday) => String(holiday.date).slice(0, 10)),
|
||||
);
|
||||
holidaysByYear.set(year, holidays);
|
||||
}
|
||||
return holidays;
|
||||
}
|
||||
|
||||
export function isWorkingPlanningDate(value: unknown): value is string {
|
||||
if (!isPlanningDate(value)) return false;
|
||||
const day = new Date(`${value}T00:00:00Z`).getUTCDay();
|
||||
return (
|
||||
day !== 0 && day !== 6 && !frenchPublicHolidaysForYear(Number(value.slice(0, 4))).has(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDeclaredDays(value: unknown): DeclaredNonWorkingDay[] {
|
||||
if (!Array.isArray(value)) throw new Error('Declared non-working days must be a list.');
|
||||
const dates = new Set<string>();
|
||||
return value.map((item: unknown) => {
|
||||
if (typeof item !== 'object' || item === null)
|
||||
throw new Error('Each declared non-working day needs a date and leave type.');
|
||||
const { date, type } = item as Record<string, unknown>;
|
||||
if (!isWorkingPlanningDate(date) || dates.has(date))
|
||||
throw new Error(
|
||||
'Declared dates must be unique working days outside French public holidays.',
|
||||
);
|
||||
if (type !== 'cp' && type !== 'rtt' && type !== 'other')
|
||||
throw new Error('Declared non-working days must have a CP, RTT, or Other type.');
|
||||
dates.add(date);
|
||||
return { date, type };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import Holidays from 'date-holidays';
|
||||
import {
|
||||
DeclaredNonWorkingDay,
|
||||
parseDeclaredDays,
|
||||
frenchPublicHolidaysForYear,
|
||||
} from './declared-non-working-days';
|
||||
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
|
||||
export type { DeclaredNonWorkingDay } from './declared-non-working-days';
|
||||
|
||||
/** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */
|
||||
export type PlanningDate = string | Date;
|
||||
@@ -55,6 +60,7 @@ export interface PlanningRequest {
|
||||
horizon: PlanningHorizon;
|
||||
balances: readonly LeaveBalanceInput[];
|
||||
rules: readonly PlanningRule[];
|
||||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||||
/** Optional caller-owned display identities copied into a calculation snapshot. */
|
||||
identities?: unknown;
|
||||
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
|
||||
@@ -186,6 +192,7 @@ export interface PlacementExplanation {
|
||||
|
||||
export interface PlanExplanation {
|
||||
kind: 'PlanExplanation';
|
||||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||||
placements: readonly PlacementExplanation[];
|
||||
/** Present when the explained plan is the incumbent of an interrupted calculation. */
|
||||
incomplete?: IncompleteExplanation;
|
||||
@@ -207,6 +214,7 @@ export interface IncompleteExplanation {
|
||||
export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation;
|
||||
|
||||
export interface PlanningSnapshot {
|
||||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||||
horizon: Readonly<{ start: string; end: string }>;
|
||||
balances: readonly Readonly<LeaveBalanceInput>[];
|
||||
rules: readonly Readonly<PlanningRule>[];
|
||||
@@ -222,6 +230,7 @@ export type TraceCandidateStatus =
|
||||
| 'ineligible';
|
||||
|
||||
export interface PlanningTraceState {
|
||||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||||
horizon: Readonly<{ start: string; end: string }>;
|
||||
balances: readonly Readonly<{
|
||||
id: string;
|
||||
@@ -361,6 +370,7 @@ export interface PlanningArtifactTimings {
|
||||
|
||||
export type CounterfactualReasonCode =
|
||||
| 'non-working'
|
||||
| 'declared-day'
|
||||
| 'occupied'
|
||||
| 'unavailable'
|
||||
| 'expired'
|
||||
@@ -424,6 +434,7 @@ export interface PlanningMetrics {
|
||||
|
||||
export interface PlanOutcome {
|
||||
kind: 'Plan';
|
||||
declaredDays?: readonly DeclaredNonWorkingDay[];
|
||||
horizon: { start: string; end: string };
|
||||
placements: LeavePlacement[];
|
||||
timeOffPeriods: TimeOffPeriod[];
|
||||
@@ -502,6 +513,7 @@ interface State {
|
||||
interface RuntimePlanningRequest {
|
||||
horizon?: unknown;
|
||||
balances?: unknown;
|
||||
declaredDays?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
@@ -515,6 +527,7 @@ interface RuntimeRecord {
|
||||
start?: unknown;
|
||||
end?: unknown;
|
||||
balances?: unknown;
|
||||
declaredDays?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
@@ -600,11 +613,17 @@ class TraceRecorder {
|
||||
private readonly workingDays: ReadonlySet<string>,
|
||||
private readonly streaming: boolean,
|
||||
) {
|
||||
const declaredDates = new Set(initialState.declaredDays?.map(({ date }) => date));
|
||||
this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end)
|
||||
.filter((date) => !workingDays.has(date))
|
||||
.filter((date) => !workingDays.has(date) || declaredDates.has(date))
|
||||
.map((date) => ({
|
||||
date,
|
||||
reason: reason('non-working', 'The date is a weekend or public holiday.'),
|
||||
reason: declaredDates.has(date)
|
||||
? reason(
|
||||
'declared-day',
|
||||
'The date is a declared non-working day fixed by the user.',
|
||||
)
|
||||
: reason('non-working', 'The date is a weekend or public holiday.'),
|
||||
}));
|
||||
this.previousBalances = new Map(
|
||||
initialState.balances.map((balance) => [balance.id, { ...balance }]),
|
||||
@@ -723,6 +742,13 @@ class TraceRecorder {
|
||||
? undefined
|
||||
: {
|
||||
horizon: { ...this.initialState.horizon },
|
||||
...(this.initialState.declaredDays
|
||||
? {
|
||||
declaredDays: this.initialState.declaredDays.map((day) => ({
|
||||
...day,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
balances: this.initialState.balances.map((balance) => ({ ...balance })),
|
||||
placements: this.initialState.placements.map((placement) => ({
|
||||
...placement,
|
||||
@@ -1080,6 +1106,8 @@ class TraceRecorder {
|
||||
}
|
||||
|
||||
interface SearchContext {
|
||||
declaredDays: readonly DeclaredNonWorkingDay[];
|
||||
declaredDates: ReadonlySet<string>;
|
||||
horizon: { start: string; end: string };
|
||||
holidays: Set<string>;
|
||||
holidayWeeks: HolidayWeekCandidate[];
|
||||
@@ -1243,7 +1271,6 @@ const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
|
||||
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
|
||||
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
|
||||
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
|
||||
const holidaysByYear = new Map<number, Set<string>>();
|
||||
|
||||
function dateKey(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
@@ -1265,6 +1292,7 @@ function runtimeRequest(value: unknown): RuntimePlanningRequest {
|
||||
return {
|
||||
horizon: source.horizon,
|
||||
balances: source.balances,
|
||||
declaredDays: source.declaredDays,
|
||||
rules: source.rules,
|
||||
identities: source.identities,
|
||||
labels: source.labels,
|
||||
@@ -1306,26 +1334,12 @@ function defaultHorizon(now = new Date()): { start: string; end: string } {
|
||||
return { start, end: addPlanningYears(start, 1) };
|
||||
}
|
||||
|
||||
function holidaysForYear(year: number): Set<string> {
|
||||
const cached = holidaysByYear.get(year);
|
||||
if (cached) return new Set(cached);
|
||||
const calendar = new Holidays('FR', { languages: ['en'] });
|
||||
const holidays = new Set<string>();
|
||||
for (const holiday of calendar.getHolidays(year, 'en')) {
|
||||
if (holiday.type !== 'public') continue;
|
||||
const key = dateKey(holiday.date);
|
||||
if (key) holidays.add(key);
|
||||
}
|
||||
holidaysByYear.set(year, holidays);
|
||||
return new Set(holidays);
|
||||
}
|
||||
|
||||
function holidaySet(horizon: { start: string; end: string }): Set<string> {
|
||||
const first = Number(horizon.start.slice(0, 4)) - 1;
|
||||
const last = Number(horizon.end.slice(0, 4)) + 1;
|
||||
const result = new Set<string>();
|
||||
for (let year = first; year <= last; year++) {
|
||||
for (const holiday of holidaysForYear(year)) result.add(holiday);
|
||||
for (const holiday of frenchPublicHolidaysForYear(year)) result.add(holiday);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1413,9 +1427,11 @@ function cloneState(state: State): State {
|
||||
function planningTraceState(
|
||||
state: State,
|
||||
horizon: { start: string; end: string },
|
||||
declaredDays: readonly DeclaredNonWorkingDay[] = [],
|
||||
): PlanningTraceState {
|
||||
return {
|
||||
horizon,
|
||||
...(declaredDays.length ? { declaredDays } : {}),
|
||||
balances: state.balances.map((balance) => ({ ...balance })),
|
||||
placements: [...state.placements.values()]
|
||||
.sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))
|
||||
@@ -1634,7 +1650,7 @@ function retainAndRecordState(
|
||||
acceptedCandidate?: MutableTraceCandidate,
|
||||
): void {
|
||||
observeTrace(context, (trace) => {
|
||||
const snapshot = planningTraceState(state, context.horizon);
|
||||
const snapshot = planningTraceState(state, context.horizon, context.declaredDays);
|
||||
if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate);
|
||||
trace.recordPlan(
|
||||
snapshot.placements,
|
||||
@@ -1893,6 +1909,7 @@ function normalizeRules(request: RuntimePlanningRequest): {
|
||||
}
|
||||
|
||||
function normalizeRequest(request: unknown): {
|
||||
declaredDays: DeclaredNonWorkingDay[];
|
||||
horizon?: { start: string; end: string };
|
||||
balances: NormalizedBalance[];
|
||||
rules: NormalizedRule[];
|
||||
@@ -1930,6 +1947,16 @@ function normalizeRequest(request: unknown): {
|
||||
reason('deadline', 'The planning deadline must be a finite non-negative number.'),
|
||||
);
|
||||
}
|
||||
let declaredDays: DeclaredNonWorkingDay[] = [];
|
||||
try {
|
||||
declaredDays = parseDeclaredDays(
|
||||
source.declaredDays === undefined ? [] : source.declaredDays,
|
||||
);
|
||||
} catch (error) {
|
||||
reasons.push(
|
||||
reason('declared-days', error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
}
|
||||
const balanceResult = normalizeBalances(source, { start, end });
|
||||
const ruleResult = normalizeRules(source);
|
||||
reasons.push(...balanceResult.reasons, ...ruleResult.reasons);
|
||||
@@ -1938,6 +1965,7 @@ function normalizeRequest(request: unknown): {
|
||||
? undefined
|
||||
: { start, end },
|
||||
balances: balanceResult.balances,
|
||||
declaredDays,
|
||||
rules: ruleResult.rules,
|
||||
reasons,
|
||||
};
|
||||
@@ -2168,6 +2196,17 @@ function assignDate(
|
||||
);
|
||||
}
|
||||
|
||||
function rejectDeclaredDate(context: SearchContext, date: string): boolean {
|
||||
if (!context.declaredDates.has(date)) return false;
|
||||
recordTraceIneligible(
|
||||
context,
|
||||
date,
|
||||
'declared-day',
|
||||
'The date is a declared non-working day fixed by the user.',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function assignDateVariants(
|
||||
state: State,
|
||||
date: string,
|
||||
@@ -2178,6 +2217,7 @@ function assignDateVariants(
|
||||
selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
|
||||
): State[] {
|
||||
context.check();
|
||||
if (rejectDeclaredDate(context, date)) return [];
|
||||
const existing = state.placements.get(date);
|
||||
if (existing) {
|
||||
if (!selectorMatches(selector, existing.type)) {
|
||||
@@ -2338,6 +2378,7 @@ function assignDateToBalance(
|
||||
>,
|
||||
): State | null {
|
||||
context.check();
|
||||
if (rejectDeclaredDate(context, date)) return null;
|
||||
if (state.placements.has(date)) {
|
||||
recordTraceCandidate(context, {
|
||||
status: 'ineligible',
|
||||
@@ -2400,6 +2441,7 @@ function assignDateToBalanceInPlace(
|
||||
>,
|
||||
): boolean {
|
||||
context.check();
|
||||
if (rejectDeclaredDate(context, date)) return false;
|
||||
if (state.placements.has(date)) {
|
||||
recordTraceCandidate(context, {
|
||||
status: 'ineligible',
|
||||
@@ -4894,6 +4936,7 @@ function planExplanation(
|
||||
const vector = objectiveVector(metrics, preferences, state, context);
|
||||
return {
|
||||
kind: 'PlanExplanation',
|
||||
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
|
||||
placements: placements.map((placement) => {
|
||||
const balance = rawBalances
|
||||
.map(recordValue)
|
||||
@@ -5051,6 +5094,7 @@ function makePlan(
|
||||
const remaining = remainingBalances(state, context);
|
||||
const plan: PlanOutcome = {
|
||||
kind: 'Plan',
|
||||
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
|
||||
horizon: context.horizon,
|
||||
placements,
|
||||
timeOffPeriods: periods,
|
||||
@@ -5642,7 +5686,7 @@ function* prepareCalendar(
|
||||
year++
|
||||
) {
|
||||
yield true;
|
||||
for (const holiday of holidaysForYear(year)) holidays.add(holiday);
|
||||
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
|
||||
progress();
|
||||
}
|
||||
const workingDays: string[] = [];
|
||||
@@ -5719,6 +5763,9 @@ function* calculatePlanningSteps(
|
||||
...balance,
|
||||
remaining: balance.quantity,
|
||||
})),
|
||||
...(normalized.declaredDays.length
|
||||
? { declaredDays: normalized.declaredDays }
|
||||
: {}),
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
@@ -5744,6 +5791,8 @@ function* calculatePlanningSteps(
|
||||
workingCalendar,
|
||||
workingPrefix,
|
||||
workingDays,
|
||||
declaredDays: normalized.declaredDays,
|
||||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||||
initialBalances: normalized.balances,
|
||||
rules: normalized.rules,
|
||||
deadline,
|
||||
@@ -5772,6 +5821,9 @@ function* calculatePlanningSteps(
|
||||
...balance,
|
||||
remaining: balance.quantity,
|
||||
})),
|
||||
...(normalized.declaredDays.length
|
||||
? { declaredDays: normalized.declaredDays }
|
||||
: {}),
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
@@ -5893,7 +5945,7 @@ function* calculatePlanningSteps(
|
||||
observeCallback(context, () =>
|
||||
checkpointCallback(
|
||||
makePlan(retained!.state, context, retained!.preferences, stages),
|
||||
planningTraceState(retained!.state, horizon),
|
||||
planningTraceState(retained!.state, horizon, context.declaredDays),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -5964,7 +6016,11 @@ function* calculatePlanningSteps(
|
||||
}
|
||||
try {
|
||||
if (explanationState)
|
||||
finalState = planningTraceState(explanationState, context.horizon);
|
||||
finalState = planningTraceState(
|
||||
explanationState,
|
||||
context.horizon,
|
||||
context.declaredDays,
|
||||
);
|
||||
} catch (error) {
|
||||
explanationError ??= reason(
|
||||
'explanation-error',
|
||||
@@ -6012,6 +6068,9 @@ function* calculatePlanningSteps(
|
||||
...balance,
|
||||
remaining: balance.quantity,
|
||||
})),
|
||||
...(normalized.declaredDays.length
|
||||
? { declaredDays: normalized.declaredDays }
|
||||
: {}),
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
},
|
||||
@@ -6079,7 +6138,11 @@ function* calculatePlanningSteps(
|
||||
let checkpointSnapshot: PlanningTraceState;
|
||||
const captureStarted = now();
|
||||
try {
|
||||
checkpointSnapshot = planningTraceState(checkpointState, context.horizon);
|
||||
checkpointSnapshot = planningTraceState(
|
||||
checkpointState,
|
||||
context.horizon,
|
||||
context.declaredDays,
|
||||
);
|
||||
} finally {
|
||||
const captureTime = now() - captureStarted;
|
||||
context.traceTime += captureTime;
|
||||
@@ -6375,6 +6438,8 @@ export function explainIncompleteCheckpoint(
|
||||
workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
|
||||
isWorking(date, holidays),
|
||||
),
|
||||
declaredDays: normalized.declaredDays,
|
||||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||||
initialBalances: normalized.balances,
|
||||
rules: normalized.rules,
|
||||
deadline: Number.POSITIVE_INFINITY,
|
||||
@@ -6433,7 +6498,7 @@ export function explainIncompleteCheckpoint(
|
||||
uncompletedStages,
|
||||
},
|
||||
},
|
||||
finalState: planningTraceState(state, horizon),
|
||||
finalState: planningTraceState(state, horizon, context.declaredDays),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -6477,6 +6542,8 @@ function counterfactualContext(
|
||||
workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
|
||||
isWorking(date, holidays),
|
||||
),
|
||||
declaredDays: normalized.declaredDays,
|
||||
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
|
||||
initialBalances: normalized.balances,
|
||||
rules: normalized.rules,
|
||||
deadline: Number.POSITIVE_INFINITY,
|
||||
@@ -6541,6 +6608,11 @@ export function historicalCounterfactualReasons(
|
||||
'non-working',
|
||||
'The date is a weekend or public holiday.',
|
||||
);
|
||||
else if (context.declaredDates.has(date))
|
||||
ineligible = counterfactualReason(
|
||||
'declared-day',
|
||||
'The date is a declared non-working day fixed by the user.',
|
||||
);
|
||||
else if (baseline.placements.has(date))
|
||||
ineligible = counterfactualReason(
|
||||
'occupied',
|
||||
@@ -6801,6 +6873,11 @@ export function analyzeCounterfactualInternal(
|
||||
'non-working',
|
||||
'The date is a weekend or public holiday.',
|
||||
);
|
||||
else if (context.declaredDates.has(date))
|
||||
ineligible = counterfactualReason(
|
||||
'declared-day',
|
||||
'The date is a declared non-working day fixed by the user.',
|
||||
);
|
||||
else if (baseline.placements.has(date))
|
||||
ineligible = counterfactualReason(
|
||||
'occupied',
|
||||
|
||||
@@ -46,6 +46,65 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
|
||||
}
|
||||
|
||||
describe('planLeave', () => {
|
||||
it('keeps declared non-working days fixed without consuming their remaining balances', async () => {
|
||||
const declaredDays = [
|
||||
{ date: '2024-01-02', type: 'cp' as const },
|
||||
{ date: '2024-01-03', type: 'rtt' as const },
|
||||
{ date: '2024-01-05', type: 'other' as const },
|
||||
{ date: '2026-10-06', type: 'other' as const },
|
||||
];
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-06' },
|
||||
balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')],
|
||||
rules: [],
|
||||
declaredDays,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ declaredDays });
|
||||
expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
|
||||
expect(result.remainingBalances).toMatchObject([{ id: 'remaining-cp', quantity: 3 }]);
|
||||
});
|
||||
|
||||
it('accepts declarations without a source balance or a new placement', async () => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon,
|
||||
balances: [],
|
||||
rules: [],
|
||||
declaredDays: [{ date: '2024-01-02', type: 'other' }],
|
||||
}),
|
||||
);
|
||||
expect(result.declaredDays).toEqual([{ date: '2024-01-02', type: 'other' }]);
|
||||
expect(result.placements).toEqual([]);
|
||||
expect(result.remainingBalances).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
[null],
|
||||
[{ date: '2024-02-30', type: 'cp' }],
|
||||
[{ date: '2024-01-06', type: 'cp' }],
|
||||
[{ date: '2024-05-08', type: 'cp' }],
|
||||
[{ date: '2024-01-02', type: 'any' }],
|
||||
[
|
||||
{ date: '2024-01-02', type: 'cp' },
|
||||
{ date: '2024-01-02', type: 'rtt' },
|
||||
],
|
||||
])('reports malformed declarations as invalid planning input: %j', async (declaredDays) => {
|
||||
const outcome = await runInNonBrowser({
|
||||
horizon,
|
||||
balances: [],
|
||||
rules: [],
|
||||
declaredDays,
|
||||
} as unknown as PlanningRequest);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'InvalidInput',
|
||||
reasons: [{ code: 'declared-days' }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
|
||||
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
|
||||
@@ -1597,6 +1656,54 @@ describe('planLeave transport', () => {
|
||||
});
|
||||
|
||||
describe('planning calculation context', () => {
|
||||
it('detaches declared dates and types across results, explanations, trace and comparisons', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const submitted: PlanningRequest = {
|
||||
horizon: { start: '2024-01-02', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2024-01-05')],
|
||||
rules: [],
|
||||
declaredDays: [
|
||||
{ date: '2024-01-02', type: 'cp' },
|
||||
{ date: '2024-01-03', type: 'rtt' },
|
||||
{ date: '2024-01-05', type: 'other' },
|
||||
],
|
||||
};
|
||||
const original = structuredClone(submitted.declaredDays);
|
||||
const calculation = createPlanningCalculation(submitted);
|
||||
submitted.declaredDays![0].type = 'other';
|
||||
submitted.horizon.end = '2024-01-03';
|
||||
expect(calculation.snapshot.declaredDays).toEqual(original);
|
||||
expect(Object.isFrozen(calculation.snapshot.declaredDays![0])).toBe(true);
|
||||
const outcome = isPlan(await calculation.outcome);
|
||||
expect(outcome.declaredDays).toEqual(original);
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
|
||||
expect(await calculation.explanation).toMatchObject({
|
||||
kind: 'PlanExplanation',
|
||||
declaredDays: original,
|
||||
placements: [{ placement: { date: '2024-01-04' } }],
|
||||
});
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.initialState.declaredDays).toEqual(original);
|
||||
expect(trace?.nonWorkingDates).toContainEqual({
|
||||
date: '2024-01-02',
|
||||
reason: expect.objectContaining({ code: 'declared-day' }),
|
||||
});
|
||||
const frozenOutcome = structuredClone(outcome);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
for (const { date } of original!)
|
||||
expect(analysis.results.find((result) => result.date === date)).toMatchObject({
|
||||
status: 'ineligible',
|
||||
reason: { code: 'declared-day' },
|
||||
historicalStatus: 'ineligible',
|
||||
});
|
||||
expect(outcome).toEqual(frozenOutcome);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
LeaveBalanceInput,
|
||||
LeavePlacement,
|
||||
LeaveType,
|
||||
DeclaredNonWorkingDay,
|
||||
PlanOutcome,
|
||||
PlanningArtifacts,
|
||||
PlanningExplanation,
|
||||
@@ -41,6 +42,7 @@ export type {
|
||||
LeavePlacement,
|
||||
LeaveSelector,
|
||||
LeaveType,
|
||||
DeclaredNonWorkingDay,
|
||||
PlanOutcome,
|
||||
PlanExplanation,
|
||||
PlacementMode,
|
||||
@@ -89,6 +91,7 @@ interface UntrustedRecord {
|
||||
start?: unknown;
|
||||
end?: unknown;
|
||||
balances?: unknown;
|
||||
declaredDays?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
@@ -116,6 +119,7 @@ interface UntrustedRecord {
|
||||
interface TransportSnapshot {
|
||||
horizon?: { start?: unknown; end?: unknown };
|
||||
balances?: unknown;
|
||||
declaredDays?: unknown;
|
||||
rules?: unknown;
|
||||
identities?: unknown;
|
||||
labels?: unknown;
|
||||
@@ -190,6 +194,9 @@ function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
|
||||
end: String(snapshotValue(horizon.end) ?? ''),
|
||||
},
|
||||
balances,
|
||||
...(request.declaredDays === undefined
|
||||
? {}
|
||||
: { declaredDays: snapshotValue(request.declaredDays) }),
|
||||
rules,
|
||||
identities: stringMap(request.identities),
|
||||
labels,
|
||||
@@ -254,6 +261,7 @@ function snapshotRequest(request: unknown): TransportSnapshot {
|
||||
}
|
||||
: undefined,
|
||||
balances,
|
||||
declaredDays: cloneUnknown(source.declaredDays),
|
||||
rules,
|
||||
identities: cloneUnknown(source.identities),
|
||||
labels: cloneUnknown(source.labels),
|
||||
@@ -363,6 +371,9 @@ function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
|
||||
earned: false,
|
||||
};
|
||||
}),
|
||||
...(Array.isArray(snapshot.declaredDays)
|
||||
? { declaredDays: snapshot.declaredDays as DeclaredNonWorkingDay[] }
|
||||
: {}),
|
||||
placements: [],
|
||||
earnedActivations: [],
|
||||
};
|
||||
@@ -1072,6 +1083,9 @@ export function applyReplayDeltas(
|
||||
function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState {
|
||||
return {
|
||||
horizon: { ...trace.initialState.horizon },
|
||||
...(trace.initialState.declaredDays
|
||||
? { declaredDays: trace.initialState.declaredDays }
|
||||
: {}),
|
||||
balances: state.balances,
|
||||
placements: [...state.placements.values()],
|
||||
earnedActivations: state.earnedActivations,
|
||||
|
||||
@@ -78,6 +78,84 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
});
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
it.each([false, true])(
|
||||
'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)',
|
||||
async (cancelled) => {
|
||||
const submitted = {
|
||||
horizon: { start: '2024-01-02', end: '2024-02-01' },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' },
|
||||
],
|
||||
rules: [],
|
||||
declaredDays: [
|
||||
{ date: '2024-01-02', type: 'cp' as const },
|
||||
{ date: '2024-01-03', type: 'rtt' as const },
|
||||
{ date: '2024-01-05', type: 'other' as const },
|
||||
],
|
||||
};
|
||||
const { calculatePlanningInternal, calculatePlanningAsyncInternal } =
|
||||
await import('./leave-planner.core');
|
||||
let interruption = false;
|
||||
const direct = cancelled
|
||||
? await calculatePlanningAsyncInternal(
|
||||
{
|
||||
...submitted,
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'non-working-day-gain') interruption = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => interruption,
|
||||
)
|
||||
: calculatePlanningInternal(submitted);
|
||||
const calculation = createPlanningCalculation({
|
||||
...submitted,
|
||||
progress: (stage) => {
|
||||
if (cancelled && stage === 'non-working-day-gain') calculation.cancel();
|
||||
},
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan');
|
||||
expect(direct.kind).toBe(outcome.kind);
|
||||
const plan =
|
||||
outcome.kind === 'Plan'
|
||||
? outcome
|
||||
: outcome.kind === 'PlanningIncomplete'
|
||||
? outcome.incumbent
|
||||
: undefined;
|
||||
const directPlan =
|
||||
direct.kind === 'Plan'
|
||||
? direct
|
||||
: direct.kind === 'PlanningIncomplete'
|
||||
? direct.incumbent
|
||||
: undefined;
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan?.declaredDays).toEqual(submitted.declaredDays);
|
||||
expect(plan?.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
|
||||
expect(directPlan?.placements).toEqual(plan?.placements);
|
||||
expect(plan?.remainingBalances).toMatchObject([{ id: 'cp', quantity: 3 }]);
|
||||
if (!cancelled) expect(outcome).toEqual(direct);
|
||||
expect(await calculation.explanation).toMatchObject({
|
||||
kind: 'PlanExplanation',
|
||||
declaredDays: submitted.declaredDays,
|
||||
});
|
||||
expect((await calculation.trace)?.initialState.declaredDays).toEqual(
|
||||
submitted.declaredDays,
|
||||
);
|
||||
const original = structuredClone(outcome);
|
||||
const comparison = await calculation.analyzeCounterfactual(plan!.placements[0]);
|
||||
for (const { date } of submitted.declaredDays)
|
||||
expect(comparison.results.find((result) => result.date === date)).toMatchObject({
|
||||
status: 'ineligible',
|
||||
reason: { code: 'declared-day' },
|
||||
});
|
||||
expect(await calculation.outcome).toEqual(original);
|
||||
},
|
||||
);
|
||||
|
||||
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
|
||||
const stages: string[] = [];
|
||||
let preparationUpdates = 0;
|
||||
@@ -85,6 +163,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
declaredDays: [{ date: '2024-01-02', type: 'other' }],
|
||||
progress: (stage) => {
|
||||
stages.push(stage);
|
||||
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
|
||||
@@ -99,7 +178,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
expect(outcome.incumbent).toBeUndefined();
|
||||
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
|
||||
expect(stages).not.toContain('obligations');
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
expect(await calculation.trace).toMatchObject({
|
||||
status: 'incomplete',
|
||||
initialState: { declaredDays: [{ date: '2024-01-02', type: 'other' }] },
|
||||
});
|
||||
expect(await calculation.explanationError).toBeUndefined();
|
||||
expect(heartbeats).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ export class PlanningDurationService {
|
||||
if (!horizon) return null;
|
||||
return {
|
||||
horizon,
|
||||
declaredDays: this.userInput.declaredDaysSignal(),
|
||||
balances: this.userInput.vacationNumberSignal().map((balance) => ({
|
||||
id: balance.id,
|
||||
type: balance.type,
|
||||
|
||||
@@ -4,6 +4,16 @@ import { storedSignal } from '../shared/utils/stored-signal';
|
||||
|
||||
export interface AppTranslations {
|
||||
locale: string;
|
||||
bookedDays: {
|
||||
enter: string;
|
||||
booked: string;
|
||||
help: string;
|
||||
fixed: string;
|
||||
removed: string;
|
||||
erase: string;
|
||||
rangeSaved: string;
|
||||
rangeRemoved: string;
|
||||
};
|
||||
app: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@@ -31,6 +31,112 @@ describe('UserInputService', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('adds, removes and replaces saved declarations without changing balances or calculating', () => {
|
||||
const balances = service.vacationNumberSignal();
|
||||
expect(service.declaredDaysSignal()).toEqual([]);
|
||||
service.toggleDeclaredDay('2024-01-02', 'cp');
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-01-02', type: 'cp' },
|
||||
]);
|
||||
service.toggleDeclaredDay('2024-01-02', 'cp');
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([]);
|
||||
service.toggleDeclaredDay('2024-01-02', 'rtt');
|
||||
service.toggleDeclaredDay('2024-01-02', 'other');
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-01-02', type: 'other' },
|
||||
]);
|
||||
expect(service.vacationNumberSignal()).toBe(balances);
|
||||
expect(service.computeTriggerSignal()).toBe(0);
|
||||
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects weekends, French national holidays and malformed calendar dates', () => {
|
||||
for (const date of [
|
||||
'2024-01-06',
|
||||
'2024-01-07',
|
||||
'2024-05-08',
|
||||
'2024-05-09',
|
||||
'2024-02-30',
|
||||
'not-a-date',
|
||||
]) {
|
||||
service.toggleDeclaredDay(date, 'cp');
|
||||
}
|
||||
expect(service.declaredDaysSignal()).toEqual([]);
|
||||
expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull();
|
||||
service.toggleDeclaredDay('2024-05-10', 'other');
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-05-10', type: 'other' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('toggles each date in a batch once and preserves unrelated declarations', () => {
|
||||
service.toggleDeclaredDay('2024-04-19', 'other');
|
||||
service.toggleDeclaredDay('2024-04-25', 'rtt');
|
||||
const balances = service.vacationNumberSignal();
|
||||
const save = vi.spyOn(Storage.prototype, 'setItem');
|
||||
service.toggleDeclaredDays(
|
||||
['2024-04-22', '2024-04-23', '2024-04-24', '2024-04-25', '2024-04-26'],
|
||||
'cp',
|
||||
);
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-04-19', type: 'other' },
|
||||
{ date: '2024-04-22', type: 'cp' },
|
||||
{ date: '2024-04-23', type: 'cp' },
|
||||
{ date: '2024-04-24', type: 'cp' },
|
||||
{ date: '2024-04-25', type: 'cp' },
|
||||
{ date: '2024-04-26', type: 'cp' },
|
||||
]);
|
||||
expect(save).toHaveBeenCalledOnce();
|
||||
service.toggleDeclaredDays(
|
||||
[
|
||||
'2024-04-25',
|
||||
'2024-04-26',
|
||||
'2024-04-27',
|
||||
'2024-04-28',
|
||||
'2024-04-29',
|
||||
'2024-04-30',
|
||||
'2024-05-01',
|
||||
'2024-04-29',
|
||||
'invalid',
|
||||
],
|
||||
'cp',
|
||||
);
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-04-19', type: 'other' },
|
||||
{ date: '2024-04-22', type: 'cp' },
|
||||
{ date: '2024-04-23', type: 'cp' },
|
||||
{ date: '2024-04-24', type: 'cp' },
|
||||
{ date: '2024-04-29', type: 'cp' },
|
||||
{ date: '2024-04-30', type: 'cp' },
|
||||
]);
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
service.toggleDeclaredDays(['2024-04-29', '2024-04-30'], null);
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
||||
{ date: '2024-04-19', type: 'other' },
|
||||
{ date: '2024-04-22', type: 'cp' },
|
||||
{ date: '2024-04-23', type: 'cp' },
|
||||
{ date: '2024-04-24', type: 'cp' },
|
||||
]);
|
||||
expect(save).toHaveBeenCalledTimes(3);
|
||||
expect(service.vacationNumberSignal()).toBe(balances);
|
||||
expect(service.computeTriggerSignal()).toBe(0);
|
||||
});
|
||||
|
||||
it('restores an empty list when saved declarations are unreadable', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
for (const saved of [
|
||||
'{',
|
||||
'null',
|
||||
'[{"date":"2024-05-08","type":"cp"}]',
|
||||
'[{"date":"2024-01-02","type":"any"}]',
|
||||
]) {
|
||||
localStorage.setItem('declaredNonWorkingDays', saved);
|
||||
expect(new UserInputService().declaredDaysSignal()).toEqual([]);
|
||||
expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull();
|
||||
}
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize with vacation days array', () => {
|
||||
const vacations = service.vacationNumberSignal();
|
||||
expect(Array.isArray(vacations)).toBe(true);
|
||||
|
||||
@@ -1,11 +1,67 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { SelectableDayType, VacDay } from './calendar-service';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
DeclaredNonWorkingDay,
|
||||
isWorkingPlanningDate,
|
||||
parseDeclaredDays,
|
||||
} from './declared-non-working-days';
|
||||
import type { LeaveType } from './leave-planner.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UserInputService {
|
||||
private readonly declaredDays = signal(this.restoreDeclaredDays());
|
||||
readonly declaredDaysSignal = this.declaredDays.asReadonly();
|
||||
readonly declarationEntrySignal = signal(false);
|
||||
readonly declaredTypeSignal = signal<LeaveType | null>('cp');
|
||||
|
||||
private restoreDeclaredDays(): DeclaredNonWorkingDay[] {
|
||||
try {
|
||||
const saved =
|
||||
typeof localStorage === 'undefined'
|
||||
? null
|
||||
: localStorage.getItem('declaredNonWorkingDays');
|
||||
return saved === null ? [] : parseDeclaredDays(JSON.parse(saved));
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore declared non-working days from localStorage.', error);
|
||||
try {
|
||||
localStorage.removeItem('declaredNonWorkingDays');
|
||||
} catch {
|
||||
// Browser storage can be unavailable; keep calendar entry usable.
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
toggleDeclaredDay(date: string, type: LeaveType): void {
|
||||
this.toggleDeclaredDays([date], type);
|
||||
}
|
||||
|
||||
toggleDeclaredDays(dates: readonly string[], type: LeaveType | null): void {
|
||||
const workingDates = new Set(dates.filter(isWorkingPlanningDate));
|
||||
if (!workingDates.size) return;
|
||||
this.declaredDays.update((days) => {
|
||||
const existing = new Map(days.map((day) => [day.date, day.type]));
|
||||
const others = days.filter((day) => !workingDates.has(day.date));
|
||||
return [
|
||||
...others,
|
||||
...(type
|
||||
? [...workingDates]
|
||||
.filter((date) => existing.get(date) !== type)
|
||||
.map((date) => ({ date, type }))
|
||||
: []),
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
});
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined')
|
||||
localStorage.setItem('declaredNonWorkingDays', JSON.stringify(this.declaredDays()));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save declared non-working days to localStorage.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private restoreDate(value: unknown): Date {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
|
||||
+19
-2
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"locale": "de-DE",
|
||||
"bookedDays": {
|
||||
"enter": "Bereits gebuchte Tage bearbeiten",
|
||||
"booked": "Bereits gebucht",
|
||||
"help": "Wählen Sie CP, RTT, Andere oder Löschen und klicken oder ziehen Sie zwischen zwei Daten, auch über mehrere Monate. Loslassen speichert; Escape bricht ab. Tage mit dem gewählten Typ werden entfernt; andere Tage erhalten diesen Typ. Wochenenden und Feiertage werden übersprungen. Schraffierte Tage sind bereits gebucht und werden beim nächsten Berechnen berücksichtigt.",
|
||||
"fixed": "Feste Benutzereingabe, ohne Abzug vom verbleibenden Guthaben oder Optimierungsentscheidung.",
|
||||
"removed": "Eintrag entfernt",
|
||||
"erase": "Löschen",
|
||||
"rangeSaved": "Gespeicherte bereits gebuchte Tage: {count} ({type}).",
|
||||
"rangeRemoved": "Entfernte Einträge: {count}."
|
||||
},
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Mehr Raum für freie Zeit.",
|
||||
@@ -11,7 +21,10 @@
|
||||
"addVacationDay": "Urlaubskonto hinzufügen",
|
||||
"calculate": "Plan berechnen",
|
||||
"durationEstimate": "Ungefähre Wartezeit: {range} {unit}",
|
||||
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||
"durationUnits": {
|
||||
"seconds": "s",
|
||||
"minutes": "min"
|
||||
},
|
||||
"remainingDaysWarning": "{count} nicht platziert:",
|
||||
"remainingDaysManualPlace": "Bitte manuell platzieren.",
|
||||
"deleteEntry": "Eintrag löschen",
|
||||
@@ -49,7 +62,11 @@
|
||||
"close": "Schließen",
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"selectTheme": "Design",
|
||||
"themeOptions": { "system": "System", "light": "Hell", "dark": "Dunkel" },
|
||||
"themeOptions": {
|
||||
"system": "System",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel"
|
||||
},
|
||||
"systemAppearance": "Aktuelles Systemdesign",
|
||||
"planningHorizon": "Planungszeitraum",
|
||||
"planningStart": "Startdatum (einschließlich)",
|
||||
|
||||
+19
-2
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"locale": "en-GB",
|
||||
"bookedDays": {
|
||||
"enter": "Edit already booked days",
|
||||
"booked": "Already booked",
|
||||
"help": "Choose CP, RTT, Other or Erase, then click or drag between dates, including across months. Release to save; Escape cancels. Each day already matching the chosen type is cleared; other days receive that type. Weekends and public holidays are skipped. Hatching marks already booked days, applied on the next calculation.",
|
||||
"fixed": "Fixed user input, with no remaining balance debit or optimization decision.",
|
||||
"removed": "Declaration removed",
|
||||
"erase": "Erase",
|
||||
"rangeSaved": "Saved already booked days: {count} ({type}).",
|
||||
"rangeRemoved": "Removed declarations: {count}."
|
||||
},
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Make more room for time off.",
|
||||
@@ -11,7 +21,10 @@
|
||||
"addVacationDay": "Add leave balance",
|
||||
"calculate": "Calculate plan",
|
||||
"durationEstimate": "Approximate wait: {range} {unit}",
|
||||
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||
"durationUnits": {
|
||||
"seconds": "s",
|
||||
"minutes": "min"
|
||||
},
|
||||
"remainingDaysWarning": "{count} vacation day{plural} not placed:",
|
||||
"remainingDaysManualPlace": "Please place them manually.",
|
||||
"deleteEntry": "Delete entry",
|
||||
@@ -49,7 +62,11 @@
|
||||
"close": "Close",
|
||||
"selectLanguage": "Language",
|
||||
"selectTheme": "Theme",
|
||||
"themeOptions": { "system": "System", "light": "Light", "dark": "Dark" },
|
||||
"themeOptions": {
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark"
|
||||
},
|
||||
"systemAppearance": "Current system appearance",
|
||||
"planningHorizon": "Planning horizon",
|
||||
"planningStart": "Start date (inclusive)",
|
||||
|
||||
+19
-2
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"locale": "es-ES",
|
||||
"bookedDays": {
|
||||
"enter": "Editar días ya reservados",
|
||||
"booked": "Ya reservado",
|
||||
"help": "Elija CP, RTT, Otro o Borrar y haga clic o arrastre entre fechas, incluso de varios meses. Suelte para guardar; Escape cancela. Cada día que ya tenga el tipo elegido se borra; los demás reciben ese tipo. Se omiten fines de semana y festivos. El rayado indica los días ya reservados, que se aplican en el próximo cálculo.",
|
||||
"fixed": "Entrada fija del usuario, sin descontar el saldo restante ni decisión de optimización.",
|
||||
"removed": "Declaración eliminada",
|
||||
"erase": "Borrar",
|
||||
"rangeSaved": "Días ya reservados guardados: {count} ({type}).",
|
||||
"rangeRemoved": "Declaraciones eliminadas: {count}."
|
||||
},
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Más espacio para descansar.",
|
||||
@@ -11,7 +21,10 @@
|
||||
"addVacationDay": "Añadir saldo de vacaciones",
|
||||
"calculate": "Calcular plan",
|
||||
"durationEstimate": "Espera aproximada: {range} {unit}",
|
||||
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||
"durationUnits": {
|
||||
"seconds": "s",
|
||||
"minutes": "min"
|
||||
},
|
||||
"remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:",
|
||||
"remainingDaysManualPlace": "Por favor, colóquelos manualmente.",
|
||||
"deleteEntry": "Eliminar entrada",
|
||||
@@ -49,7 +62,11 @@
|
||||
"close": "Cerrar",
|
||||
"selectLanguage": "Idioma",
|
||||
"selectTheme": "Tema",
|
||||
"themeOptions": { "system": "Sistema", "light": "Claro", "dark": "Oscuro" },
|
||||
"themeOptions": {
|
||||
"system": "Sistema",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro"
|
||||
},
|
||||
"systemAppearance": "Apariencia actual del sistema",
|
||||
"planningHorizon": "Horizonte de planificación",
|
||||
"planningStart": "Fecha de inicio (incluida)",
|
||||
|
||||
+19
-2
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"locale": "fr-FR",
|
||||
"bookedDays": {
|
||||
"enter": "Modifier les jours déjà posés",
|
||||
"booked": "Déjà posé",
|
||||
"help": "Choisissez CP, RTT, Autre ou Effacer, puis cliquez ou faites glisser entre deux dates, même sur plusieurs mois. Relâchez pour enregistrer ; Échap annule. Chaque jour du type choisi est retiré ; les autres prennent ce type. Week-ends et jours fériés sont ignorés. Les hachures indiquent les jours déjà posés, pris en compte au prochain calcul.",
|
||||
"fixed": "Saisie utilisateur fixe, sans débit du solde restant ni décision d’optimisation.",
|
||||
"removed": "Déclaration supprimée",
|
||||
"erase": "Effacer",
|
||||
"rangeSaved": "Jours déjà posés enregistrés : {count} ({type}).",
|
||||
"rangeRemoved": "Déclarations supprimées : {count}."
|
||||
},
|
||||
"app": {
|
||||
"title": "vac-optimizer",
|
||||
"description": "Faites plus de place aux vacances.",
|
||||
@@ -11,7 +21,10 @@
|
||||
"addVacationDay": "Ajouter un solde de congés",
|
||||
"calculate": "Calculer le planning",
|
||||
"durationEstimate": "Attente indicative : {range} {unit}",
|
||||
"durationUnits": { "seconds": "s", "minutes": "min" },
|
||||
"durationUnits": {
|
||||
"seconds": "s",
|
||||
"minutes": "min"
|
||||
},
|
||||
"remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:",
|
||||
"remainingDaysManualPlace": "Veuillez les placer manuellement.",
|
||||
"deleteEntry": "Supprimer l’entrée",
|
||||
@@ -49,7 +62,11 @@
|
||||
"close": "Fermer",
|
||||
"selectLanguage": "Langue",
|
||||
"selectTheme": "Thème",
|
||||
"themeOptions": { "system": "Système", "light": "Clair", "dark": "Sombre" },
|
||||
"themeOptions": {
|
||||
"system": "Système",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre"
|
||||
},
|
||||
"systemAppearance": "Apparence actuelle du système",
|
||||
"planningHorizon": "Horizon de planification",
|
||||
"planningStart": "Date de début (incluse)",
|
||||
|
||||
@@ -60,6 +60,11 @@ html {
|
||||
--rtt-bg: #c8e1f5;
|
||||
--cp-bg: #bde5d5;
|
||||
--other-bg: #e2d4ee;
|
||||
--declared-day-pattern: repeating-linear-gradient(
|
||||
135deg,
|
||||
transparent 0 5px,
|
||||
color-mix(in srgb, currentColor 14%, transparent) 5px 7px
|
||||
);
|
||||
--loading-overlay-bg: rgba(255, 255, 255, 0.92);
|
||||
--loading-text-color: #333;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user