Compare commits

...

5 commits

Author SHA1 Message Date
Julian Pawlowski
63c3404fbd Merge branch 'main' of https://github.com/jpawlowski/hass.tibber_prices
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
2026-04-18 09:53:48 +00:00
Julian Pawlowski
7783a0b629 refactor(periods): enhance period gap handling and cross-day validation
Improve the logic for trimming trailing gap-tolerance intervals in periods to prevent misleading period end shifts. Additionally, refine cross-day boundary validation for both best and peak periods to eliminate false extremes caused by inter-day reference shifts.

Impact: More accurate period calculations and reduced artifacts in reported price periods.
2026-04-18 09:53:31 +00:00
dependabot[bot]
63a187fe5c
chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#119) 2026-04-18 08:56:12 +02:00
dependabot[bot]
9a4ee04cfa
chore(deps-dev): bump typescript from 6.0.2 to 6.0.3 in /docs/user (#120) 2026-04-18 08:55:57 +02:00
dependabot[bot]
d66b3f4ec0
chore(deps-dev): bump typescript from 6.0.2 to 6.0.3 in /docs/developer (#121) 2026-04-18 08:48:23 +02:00
11 changed files with 240 additions and 45 deletions

View file

@ -34,7 +34,7 @@ jobs:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.9.3"

View file

@ -50,6 +50,22 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve
return ref_prices
def _trim_trailing_gaps(period: list[dict]) -> list[dict]:
"""Remove trailing gap-tolerance intervals from a period.
Gap-tolerance intervals at the trailing edge of a period represent
the transition out of the period's price level (e.g., the first
NORMAL interval after a sequence of EXPENSIVE ones). Keeping them
shifts the reported period end by up to gap_count intervals.
Interior gaps (surrounded by qualifying intervals on both sides)
are kept because they represent brief dips within an otherwise
continuous period.
"""
while period and period[-1].get("is_level_gap", False):
period = period[:-1]
return period
def build_periods(
all_prices: list[dict],
price_context: dict[str, Any],
@ -171,11 +187,19 @@ def build_periods(
effective_criteria = criteria._replace(flex=criteria.flex + geo_bonus) if geo_bonus > 0 else criteria
in_flex, meets_min_distance = check_interval_criteria(price_for_criteria, effective_criteria)
# Cross-day boundary validation for peak periods:
# Cross-day boundary validation (symmetric for best AND peak periods):
# Overnight intervals (00:00-05:59) must ALSO qualify against the previous
# day's reference price. Without this, prices like 30ct become "peak" against
# tomorrow's lower max (35ct) but weren't peak against today's higher max (39ct).
if reverse_sort and in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
# day's reference price. This prevents day-boundary artifacts in BOTH directions:
#
# PEAK example: A 30ct interval becomes "peak" against tomorrow's lower max (35ct)
# but wasn't peak against today's higher max (39ct).
# BEST example: An 8ct interval becomes "best" against today's lower min (5ct, flex
# allows ≤7.5ct) but actually a 7ct interval qualifying today wouldn't have
# qualified yesterday when min was 4ct (flex allows ≤6ct).
#
# In both cases the apparent "extreme" is just a relative shift between adjacent
# days, not a genuine outlier worth reporting.
if in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
prev_day = date_key - timedelta(days=1)
prev_criteria = criteria_by_day.get(prev_day)
if prev_criteria is not None:
@ -227,14 +251,21 @@ def build_periods(
}
)
elif current_period:
# Criteria no longer met, end current period
periods.append(current_period)
# Criteria no longer met, end current period.
# Trim trailing gap-tolerance intervals: these sit at the boundary
# between the period's price level and the next, and would shift
# the period end by up to gap_count intervals.
current_period = _trim_trailing_gaps(current_period)
if current_period:
periods.append(current_period)
current_period = []
consecutive_gaps = 0 # Reset gap counter
# Add final period if exists
if current_period:
periods.append(current_period)
current_period = _trim_trailing_gaps(current_period)
if current_period:
periods.append(current_period)
# Log detailed filter statistics
if intervals_checked > 0:
@ -690,6 +721,12 @@ def _is_period_eligible_for_extension(
- Period ends on today (not yesterday or tomorrow)
- Period ends late (after late_hour_threshold, e.g. 20:00)
Note: ``end`` is an *exclusive* boundary the first moment after the last
interval. A period whose last interval starts at 23:45 has ``end = 00:00``
the next calendar day. We therefore derive the effective date/hour from
``end 1 minute`` so that such periods are correctly recognised as ending
"today, late in the evening".
"""
period_end = period.get("end")
period_start = period.get("start")
@ -697,10 +734,13 @@ def _is_period_eligible_for_extension(
if not period_end or not period_start:
return False
if period_end.date() != today:
# Derive last-covered moment (exclusive end → inclusive last moment)
effective_end = period_end - timedelta(minutes=1)
if effective_end.date() != today:
return False
return period_end.hour >= late_hour_threshold
return effective_end.hour >= late_hour_threshold
def _find_extension_intervals(

View file

@ -89,6 +89,20 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
The newer period's relaxation attributes override the older period's.
Takes the earliest start time and latest end time.
Price statistics are recombined from both periods so the merged period
reflects the actual span (rather than only period1's stats):
- price_min: min(period1.price_min, period2.price_min)
- price_max: max(period1.price_max, period2.price_max)
- price_spread: max - min (recomputed)
- price_mean: weighted by period_interval_count when available, else
weighted by duration_minutes (kept simple - exact mean would require
raw interval prices that aren't carried in the period dict).
Note: price_median and price_coefficient_variation_% are intentionally NOT
recomputed because they cannot be derived from summary stats. They retain
period1's values; downstream consumers must treat them as approximate for
merged periods (the `merged_from` marker indicates this).
Relaxation attributes from the newer period (period2) override those from period1:
- relaxation_active
- relaxation_level
@ -102,7 +116,8 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
period2: Second period (newer relaxed period with higher flex)
Returns:
Merged period dict with combined time span and newer period's attributes
Merged period dict with combined time span, recomputed price extremes,
and the newer period's relaxation attributes.
"""
# Take earliest start and latest end
@ -110,7 +125,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
merged_end = max(period1["end"], period2["end"])
merged_duration = int((merged_end - merged_start).total_seconds() / 60)
# Start with period1 as base
# Start with period1 as base (keeps period_position, period_count_*, ratings, etc.)
merged = period1.copy()
# Update time boundaries
@ -118,6 +133,39 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
merged["end"] = merged_end
merged["duration_minutes"] = merged_duration
# Recombine price extremes from both periods
p1_min = period1.get("price_min")
p2_min = period2.get("price_min")
p1_max = period1.get("price_max")
p2_max = period2.get("price_max")
if p1_min is not None and p2_min is not None:
merged["price_min"] = round(min(float(p1_min), float(p2_min)), 4)
if p1_max is not None and p2_max is not None:
merged["price_max"] = round(max(float(p1_max), float(p2_max)), 4)
if merged.get("price_min") is not None and merged.get("price_max") is not None:
merged["price_spread"] = round(float(merged["price_max"]) - float(merged["price_min"]), 4)
# Weighted mean: prefer interval count, fall back to duration
p1_mean = period1.get("price_mean")
p2_mean = period2.get("price_mean")
if p1_mean is not None and p2_mean is not None:
p1_weight = period1.get("period_interval_count") or period1.get("duration_minutes") or 1
p2_weight = period2.get("period_interval_count") or period2.get("duration_minutes") or 1
total_weight = p1_weight + p2_weight
if total_weight > 0:
merged["price_mean"] = round(
(float(p1_mean) * p1_weight + float(p2_mean) * p2_weight) / total_weight,
4,
)
# Combine interval count if both have it (overlaps will overcount slightly,
# which is acceptable for the weighted-mean use case above)
p1_iv = period1.get("period_interval_count")
p2_iv = period2.get("period_interval_count")
if p1_iv is not None and p2_iv is not None:
merged["period_interval_count"] = int(p1_iv) + int(p2_iv)
# Override with period2's relaxation attributes (newer/higher flex wins)
relaxation_attrs = [
"relaxation_active",
@ -132,7 +180,8 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
if attr in period2:
merged[attr] = period2[attr]
# Mark as merged (for debugging)
# Mark as merged (for debugging) - downstream consumers can detect that
# price_median / price_coefficient_variation_% are approximate.
merged["merged_from"] = {
"period1_start": period1["start"].isoformat(),
"period1_end": period1["end"].isoformat(),
@ -141,7 +190,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
}
_LOGGER_DETAILS.debug(
"%sMerged periods: %s-%s + %s-%s%s-%s (duration: %d min)",
"%sMerged periods: %s-%s + %s-%s%s-%s (duration: %d min, mean: %s)",
INDENT_L2,
period1["start"].strftime("%H:%M"),
period1["end"].strftime("%H:%M"),
@ -150,6 +199,7 @@ def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
merged_start.strftime("%H:%M"),
merged_end.strftime("%H:%M"),
merged_duration,
merged.get("price_mean"),
)
return merged

View file

@ -21,6 +21,7 @@ from .types import (
INDENT_L2,
LOW_PRICE_QUALITY_BYPASS_THRESHOLD,
PERIOD_MAX_CV,
RELAXATION_FLEX_INCREMENT,
TibberPricesPeriodConfig,
)
@ -284,6 +285,7 @@ def _try_min_duration_fallback(
existing_periods: list[dict],
prices_by_day: dict[date, list[dict]],
time: TibberPricesTimeService,
max_relaxation_attempts: int = 0,
day_patterns_by_date: dict | None = None,
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
"""
@ -351,19 +353,32 @@ def _try_min_duration_fallback(
current_min_duration,
)
# Create modified config with shorter min_period_length
# Use maxed-out flex (50%) since we're in fallback mode
# Create modified config with shorter min_period_length.
# IMPORTANT: We deliberately do NOT max out flex/min_distance here.
# Going to MAX_FLEX_HARD_LIMIT (50%) and disabling min_distance + level filter
# made every interval qualify on flat-price days, producing phantom periods that
# don't represent any real "best/peak" structure. Instead we keep the relaxation's
# final flex (the highest the user accepted via max_relaxation_attempts) and
# only:
# - drop the level filter (it was already dropped during the last relaxation step)
# - halve min_distance_from_avg (instead of zeroing it) so genuinely flat days
# still surface no period rather than a misleading one.
# The shorter min_period_length is what actually unlocks new candidates.
relaxation_final_flex = min(
abs(config.flex) + max(1, max_relaxation_attempts) * RELAXATION_FLEX_INCREMENT,
MAX_FLEX_HARD_LIMIT,
)
fallback_config = TibberPricesPeriodConfig(
reverse_sort=config.reverse_sort,
flex=MAX_FLEX_HARD_LIMIT, # Max flex
min_distance_from_avg=0, # Disable min_distance in fallback
flex=relaxation_final_flex,
min_distance_from_avg=config.min_distance_from_avg * 0.5,
min_period_length=current_min_duration,
threshold_low=config.threshold_low,
threshold_high=config.threshold_high,
threshold_volatility_moderate=config.threshold_volatility_moderate,
threshold_volatility_high=config.threshold_volatility_high,
threshold_volatility_very_high=config.threshold_volatility_very_high,
level_filter=None, # Disable level filter
level_filter="any", # Already effectively any after relaxation; keeps gap logic intact
gap_count=config.gap_count,
extend_to_extreme=config.extend_to_extreme,
max_extension_intervals=config.max_extension_intervals,
@ -548,16 +563,21 @@ def calculate_periods_with_relaxation(
time_range: tuple[datetime, datetime] | None = None,
) -> dict[str, Any]:
"""
Calculate periods with optional per-day filter relaxation.
Calculate periods with optional global filter relaxation and per-day target tracking.
NEW: Each day gets its own independent relaxation loop. Today can be in Phase 1
while tomorrow is in Phase 3, ensuring each day finds enough periods.
Strategy: a single global relaxation loop iterates flex levels (3% steps from
the configured base flex up to MAX_FLEX_HARD_LIMIT). After every step we re-run
period detection across all available days and check, per day, how many quality
periods (CV PERIOD_MAX_CV) have accumulated. Days that already meet the target
(`min_periods`) are not re-processed; the loop exits as soon as **all** days meet
their target. Days with very flat prices automatically need only 1 period
(see `_compute_day_effective_min`).
If min_periods is not reached with normal filters, this function gradually
relaxes filters in multiple phases FOR EACH DAY SEPARATELY:
If after all flex levels some days still have ZERO periods, a last-resort
`min_period_length` fallback is attempted (see `_try_min_duration_fallback`).
Phase 1: Increase flex threshold step-by-step (up to max_relaxation_attempts)
Phase 2: Disable level filter (set to "any")
Phase 2: Disable level filter (set to "any") in combination with each flex step
Args:
all_prices: All price data points
@ -818,6 +838,7 @@ def calculate_periods_with_relaxation(
existing_periods=all_periods,
prices_by_day=prices_by_day,
time=time,
max_relaxation_attempts=max_relaxation_attempts,
day_patterns_by_date=day_patterns_by_date,
)
@ -915,7 +936,7 @@ def relax_all_prices(
# Import here to avoid circular dependency
from .core import calculate_periods # noqa: PLC0415
flex_increment = 0.03 # 3% per step (hard-coded for reliability)
flex_increment = RELAXATION_FLEX_INCREMENT # 3% per step (see types.py for rationale)
base_flex = abs(config.flex)
original_level_filter = config.level_filter
existing_periods = list(baseline_periods) # Start with baseline

View file

@ -56,6 +56,13 @@ PEAK_MIN_PREMIUM_ABOVE_AVG_PCT = 10.0 # Peak mean must be ≥ 10% above daily a
# but weren't peak against today's higher max.
CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6 # Validate 00:00-05:59 against previous day too
# Relaxation flex increment per step (decimal, e.g. 0.03 = 3% per step).
# Hard-coded for reliability and predictability across all callers (see
# docs/developer/docs/period-calculation-theory.md). Keeps escalation moderate
# even when the user configures a high base flex (a high base would otherwise
# cause runaway escalation, e.g. base 40% × 1.25 → 50% in a single step).
RELAXATION_FLEX_INCREMENT = 0.03
# Log indentation levels for visual hierarchy
INDENT_L0 = "" # Top level (calculate_periods_with_relaxation)
INDENT_L1 = " " # Per-day loop

View file

@ -501,6 +501,42 @@ All three thresholds are internal correctness guards, not user preferences:
---
## Cross-Day Boundary Validation
### Why Boundary Validation Exists
Each calendar day has its own min, max, and average price. An interval near midnight is evaluated against the reference of the day it belongs to (`its own day` rule, see `period_building.py`). This is fair across day boundaries because price uncertainty differs between the last quarter of one day and the first quarter of the next. However, it introduces an artifact:
- An interval can pass the flex/distance test against its own day **only because** the neighbouring day happens to have a more extreme reference (lower min for "best", higher max for "peak"). The interval is not actually a meaningful extreme — it is just sitting on a relative slope between two days.
### Symmetric Dual-Day Check (Best _and_ Peak)
Implementation: `period_building.py` → inside `build_periods()` interval loop.
Intervals starting in `[00:00, CROSS_DAY_OVERNIGHT_VALIDATION_HOUR)` (default 06:00) must additionally pass the previous day's flex test:
```python
if in_flex and starts_at.hour < CROSS_DAY_OVERNIGHT_VALIDATION_HOUR:
prev_criteria = criteria_by_day.get(date_key - timedelta(days=1))
if prev_criteria is not None:
in_prev_flex, _ = check_interval_criteria(price_for_criteria, prev_criteria)
if not in_prev_flex:
in_flex = False # boundary artifact, drop it
```
The check applies to **both** directions:
- **Peak example:** Tomorrow's max=35 ct, today's max=39 ct, flex=15% → 30 ct passes against tomorrow (≤35 ct) but not against today (≤33.15 ct). It is dropped.
- **Best example:** Today's min=5 ct, yesterday's min=4 ct, flex=50% → 7 ct passes against today (≤7.5 ct) but not against yesterday (≤6 ct). It is dropped.
Without the symmetric check, both directions produced "false extremes" purely from inter-day reference shifts. The check adds at most one extra criteria evaluation per qualifying interval and only for the first ~6 hours of each day.
### Hour Cutoff Choice
`CROSS_DAY_OVERNIGHT_VALIDATION_HOUR = 6` (in `types.py`) covers the typical overnight low/high window. Beyond that, intra-day price dynamics dominate and the boundary artifact disappears naturally.
---
## Relaxation Strategy
### Purpose
@ -610,6 +646,44 @@ Day 2025-11-19:
---
## Last-Resort Min-Duration Fallback
### When It Triggers
After every flex level has been tried during relaxation, some days may still have **zero** periods (not just below target). For those days only, `_try_min_duration_fallback()` (`relaxation.py`) progressively shortens `min_period_length`:
```text
60 min → 45 min → 30 min (MIN_DURATION_FALLBACK_MINIMUM)
```
A shorter minimum lets a 2-interval qualifying window (30 min) become a real period instead of being discarded.
### What Stays Strict
The fallback intentionally does **not** maximise every other knob. Earlier versions disabled the level filter, set `min_distance_from_avg = 0` and pinned flex at `MAX_FLEX_HARD_LIMIT (50%)`. The combination accepted essentially any interval and produced phantom periods on flat-price days where no real "best" or "peak" structure exists.
The current behaviour is:
| Knob | Fallback value | Why |
|------|---------------|-----|
| `flex` | `min(base_flex + max_relaxation_attempts × RELAXATION_FLEX_INCREMENT, MAX_FLEX_HARD_LIMIT)` | Same flex the user implicitly accepted via the relaxation cap |
| `min_distance_from_avg` | `config.min_distance_from_avg × 0.5` | Halved, not zeroed — flat days still produce no period |
| `level_filter` | `"any"` | Already effectively any after the last relaxation step |
| `min_period_length` | progressively shortened | The actual lever that unlocks new candidates |
### Marker Attributes
Periods produced by the fallback get:
- `duration_fallback_active: true`
- `duration_fallback_min_length: <minutes>`
- `relaxation_active: true`
- `relaxation_level: "duration_fallback=<n>min"`
Downstream consumers (binary sensors, diagnostics) can detect these and surface a less confident UI state if desired.
---
## Implementation Notes
### Key Files and Functions
@ -1358,4 +1432,5 @@ Low volatility (< 15%) means classification changes are less economically signif
## Changelog
- **2026-04-17**: Documented symmetric cross-day boundary validation (best _and_ peak), smarter min-duration fallback (no longer maxes out flex/min_distance), and the new `RELAXATION_FLEX_INCREMENT` constant. Recorded that period merging now recombines `price_min`/`price_max`/`price_spread`/`price_mean` (weighted by interval count) instead of inheriting only from the older period.
- **2025-11-19**: Initial documentation of Flex/Distance interaction and Relaxation strategy fixes

View file

@ -23,7 +23,7 @@
"@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"typescript": "~6.0.2"
"typescript": "~6.0.3"
},
"engines": {
"node": ">=20.0"
@ -19885,9 +19885,9 @@
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {

View file

@ -30,7 +30,7 @@
"@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"typescript": "~6.0.2"
"typescript": "~6.0.3"
},
"browserslist": {
"production": [

View file

@ -281,10 +281,10 @@ Late-evening periods (starting after 20:00) are extended into the next day if pr
- Extension stops if prices deviate more than 15% from the original period's mean
**Day-boundary artifact filtering:**
Each day has its own min/max/avg — so the same absolute price can qualify as "cheap" on one day but not the next. The integration catches misleading artifacts with several automatic checks:
- **Peak periods** near midnight must qualify against **both** adjacent days' statistics
- **Peak periods** must exceed the daily average by at least 10% (overnight periods use the higher average of both days)
- Early-morning "peaks" that are significantly weaker than yesterday's late-evening peak are recognized as artifacts and filtered out
Each day has its own min/max/avg — so the same absolute price can qualify as "cheap" or "peak" on one day but not the next. The integration catches these misleading artifacts with several automatic checks:
- **Both Best _and_ Peak periods** near midnight (00:00-05:59) must qualify against **both** adjacent days' reference prices. Without this, a 7 ct interval could become "best" only because today's minimum happens to be lower than yesterday's, or a 30 ct interval could become "peak" only because tomorrow's maximum happens to be lower than today's.
- **Peak periods** must exceed the daily average by at least 10% (overnight periods use the higher average of both days).
- Early-morning "peaks" that are significantly weaker than yesterday's late-evening peak are recognized as artifacts and filtered out.
These checks run automatically — no configuration needed.
@ -315,11 +315,13 @@ flowchart LR
If all relaxation steps are exhausted and some days still have **zero** periods:
- Minimum duration is progressively reduced: 60 → 45 → 30 minutes
- All other filters are maximally relaxed (50% flex, no distance or level filter)
- Periods found this way are marked with `duration_fallback_active: true`
- Minimum duration is progressively reduced: 60 → 45 → 30 minutes (so a shorter price window is enough to qualify as a period).
- The flex level reached during relaxation is kept (typically 45-48%) — it is **not** maxed out further.
- The minimum-distance-from-average filter is halved (not disabled) so genuinely flat days still surface no period rather than a misleading one.
- The price level filter is dropped (any level allowed).
- Periods found this way are marked with `duration_fallback_active: true`.
This ensures that every day has at least one period, even under extreme market conditions.
This ensures that every day has at least one period under realistic market conditions, while still suppressing phantom periods on extremely flat days where no meaningful "best" or "peak" window exists.
### Visual Example
@ -893,7 +895,7 @@ Daily average: 19 ct/kWh
The [cross-day handling](#phase-5-cross-day-handling) automatically prevents misleading period boundaries at midnight:
- **Peak periods** near midnight are validated against **both** adjacent days' statistics
- **Best _and_ Peak periods** near midnight are validated against **both** adjacent days' statistics
- **Peak periods** must exceed the daily average by at least 10%, with overnight periods checked against the higher average of both days
- **Cross-day extensions** are capped in length and stop when prices deviate significantly

View file

@ -24,7 +24,7 @@
"@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"typescript": "~6.0.2"
"typescript": "~6.0.3"
},
"engines": {
"node": ">=20.0"
@ -20086,9 +20086,9 @@
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {

View file

@ -31,7 +31,7 @@
"@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"typescript": "~6.0.2"
"typescript": "~6.0.3"
},
"browserslist": {
"production": [