Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Allow negative offsets and show empty segments when no data is available #494

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 32 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,28 +58,28 @@ Otherwise, the integration may complain of a duplicate unique ID.

## Options

| Name | Type | Requirement | Description | Default |
|----------------------------------|------------------|--------------|----------------------------------------------------------------|---------------------|
| `type` | string | **Required** | `custom:hourly-weather` | |
| `entity` | string | **Required** | Home Assistant weather entity ID. | |
| `name` | string | **Optional** | Card name (set to `null` to hide) | `Hourly Weather` |
| `icons` | bool | **Optional** | Whether to show icons instead of text labels | `false` |
| `num_segments` | number | **Optional** | Number of forecast segments to show (integer >= 1) | `12` |
| ~~`num_hours`~~ | number | **Optional** | _Deprecated:_ Use `num_segments` instead | `12` |
| `offset` | number | **Optional** | Number of forecast segments to offset from start | `0` |
| `label_spacing` | number | **Optional** | Space between time/temperature labels (integer >= 1) | `2` |
| `colors` | [object][color] | **Optional** | Set colors for all or some conditions | |
| `hide_hours` | bool | **Optional** | Whether to hide hour labels under the bar | `false` |
| `hide_temperatures` | bool | **Optional** | Whether to hide temperatures under the bar | `false` |
| `hide_bar` | bool | **Optional** | Whether to hide the bar itself | `false` |
| `show_wind` | [Wind][wind] | **Optional** | Whether to show wind speed and/or direction under the bar | `'false'` |
| `show_precipitation_amounts` | bool | **Optional** | Whether to show precipitation (rain) amount under the bar | `false` |
| `show_precipitation_probability` | bool | **Optional** | Whether to show precipitation (rain) probability under the bar | `false` |
| `show_date` | [string][dates] | **Optional** | Whether to show date under the bar | `'false'` |
| `tap_action` | [object][action] | **Optional** | Action to take on tap | `action: more-info` |
| `hold_action` | [object][action] | **Optional** | Action to take on hold | `none` |
| `double_tap_action` | [object][action] | **Optional** | Action to take on double tap | `none` |
| `language` | string | **Optional** | Language to use for card (overrides HA & user settings) | |
| Name | Type | Requirement | Description | Default |
|----------------------------------|------------------|--------------|------------------------------------------------------------------------------------------|---------------------|
| `type` | string | **Required** | `custom:hourly-weather` | |
| `entity` | string | **Required** | Home Assistant weather entity ID. | |
| `name` | string | **Optional** | Card name (set to `null` to hide) | `Hourly Weather` |
| `icons` | bool | **Optional** | Whether to show icons instead of text labels | `false` |
| `num_segments` | number | **Optional** | Number of forecast segments to show (integer >= 1) | `12` |
| ~~`num_hours`~~ | number | **Optional** | _Deprecated:_ Use `num_segments` instead | `12` |
| `offset` | number | **Optional** | Number of forecast segments to offset from start. A negative value will insert empty segments at the beginning | `0` |
| `label_spacing` | number | **Optional** | Space between time/temperature labels (integer >= 1) | `2` |
| `colors` | [object][color] | **Optional** | Set colors for all or some conditions | |
| `hide_hours` | bool | **Optional** | Whether to hide hour labels under the bar | `false` |
| `hide_temperatures` | bool | **Optional** | Whether to hide temperatures under the bar | `false` |
| `hide_bar` | bool | **Optional** | Whether to hide the bar itself | `false` |
| `show_wind` | [Wind][wind] | **Optional** | Whether to show wind speed and/or direction under the bar | `'false'` |
| `show_precipitation_amounts` | bool | **Optional** | Whether to show precipitation (rain) amount under the bar | `false` |
| `show_precipitation_probability` | bool | **Optional** | Whether to show precipitation (rain) probability under the bar | `false` |
| `show_date` | [string][dates] | **Optional** | Whether to show date under the bar | `'false'` |
| `tap_action` | [object][action] | **Optional** | Action to take on tap | `action: more-info` |
| `hold_action` | [object][action] | **Optional** | Action to take on hold | `none` |
| `double_tap_action` | [object][action] | **Optional** | Action to take on double tap | `none` |
| `language` | string | **Optional** | Language to use for card (overrides HA & user settings) | |

> Note that some of the more advanced options are not available in the card editor UI and must be configured via YAML.

Expand Down Expand Up @@ -114,6 +114,16 @@ label_spacing: |
{{ 4 if segments > 12 else 2 }}
```

Or show the rest of today's weather while keeping the same layout throughout the day:

```yaml
name: Today
num_segments: 24
offset: |
{{ -now().hour }}
label_spacing: 4
```

## Action Options

| Name | Type | Requirement | Description | Default |
Expand Down
28 changes: 0 additions & 28 deletions cypress/e2e/config.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,6 @@ describe('Config', () => {
.find('p')
.should('have.text', 'num_segments must be a positive integer');
});
it('errors for offset < 0', () => {
cy.configure({
offset: '-1'
});
cy.get('hui-error-card')
.shadow()
.find('p')
.should('have.text', 'offset must be a positive integer');
});
it('errors for num_segments > forecast length', () => {
cy.configure({
num_segments: '50'
});
cy.get('hui-error-card')
.shadow()
.find('p')
.should('have.text', 'Too many forecast segments requested in num_segments. Must be <= number of segments in forecast entity.');
});
it('errors for num_segments > forecast length - offset', () => {
cy.configure({
num_segments: '12',
offset: '50'
});
cy.get('hui-error-card')
.shadow()
.find('p')
.should('have.text', 'Too many forecast segments requested in num_segments. Must be <= number of segments in forecast entity.');
});
it('errors for wind barbs when entity uses cardinal directions for wind bearing', () => {
cy.addEntity({
'weather.wind_bearing_string': {
Expand Down
139 changes: 139 additions & 0 deletions cypress/e2e/weather-bar.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,4 +926,143 @@ describe('Weather bar', () => {
});
});
});
describe('Empty segments', () => {
const expectedTemperatures = [
84,
85,
84,
79,
70,
65
];
it('shows empty segments at the beginning if offset < 0', () => {
cy.configure({
offset: '-1'
});

cy.get('weather-bar')
.shadow()
.find('div.bar > div:first-child')
.should('have.css', 'grid-column-start', '1')
.should('have.css', 'grid-column-end', '3')
.should('have.attr', 'data-tippy-content', 'No data')
.should('have.class', 'empty');
});
it('aligns forecast segments correctly if offset < 0', () => {
cy.configure({
offset: '-1'
});

cy.get('weather-bar')
.shadow()
.find('div.axes > div.bar-block div.temperature')
.should('have.length', 12)
.each((el, i) => {
if (i % 2 === 1) {
cy.wrap(el).should('have.text', expectedTemperatures[(i - 1) / 2] + '°');
}
});
});

it('fills the entire bar if offset < -num_segments', () => {
cy.configure({
offset: '-20'
});

cy.get('weather-bar')
.shadow()
.find('div.bar > div')
.should('have.length', 1)
.should('have.css', 'grid-column-start', '1')
.should('have.css', 'grid-column-end', '25')
.should('have.attr', 'data-tippy-content', 'No data')
.should('have.class', 'empty');
});


it('shows empty segments at the end if (offset + num_segments) > available data', () => {
cy.configure({
offset: '40'
});

cy.get('weather-bar')
.shadow()
.find('div.bar > div:last-child')
.should('have.css', 'grid-column-start', '17')
.should('have.css', 'grid-column-end', '25')
.should('have.attr', 'data-tippy-content', 'No data')
.should('have.class', 'empty');
});

it('fills the entire bar if offset > available forecast data', () => {
cy.configure({
offset: '50'
});

cy.get('weather-bar')
.shadow()
.find('div.bar > div')
.should('have.length', 1)
.should('have.css', 'grid-column-start', '1')
.should('have.css', 'grid-column-end', '25')
.should('have.attr', 'data-tippy-content', 'No data')
.should('have.class', 'empty');
});
it('hides axes for empty segment at start', () => {
cy.configure({
offset: '-1'
});
cy.get('weather-bar')
.shadow()
.find('div.axes > div.bar-block > div.bar-block-left, div.axes > div.bar-block > div.bar-block-right')
.should('have.length', 24)
.each((el, i) => {
if (i < 2) {
cy.wrap(el).should('have.class', 'bar-block-empty');
} else {
cy.wrap(el).should('not.have.class', 'bar-block-empty');
}
});
});
it('hides axes for empty segment at end', () => {
cy.configure({
offset: '40'
});
cy.get('weather-bar')
.shadow()
.find('div.axes > div.bar-block > div.bar-block-left, div.axes > div.bar-block > div.bar-block-right')
.should('have.length', 24)
.each((el, i) => {
if (i >= 16) {
cy.wrap(el).should('have.class', 'bar-block-empty');
} else {
cy.wrap(el).should('not.have.class', 'bar-block-empty');
}
});
});
it('hides axes for empty segment filling the entire bar (offset < num_segments)', () => {
cy.configure({
offset: '-20'
});
cy.get('weather-bar')
.shadow()
.find('div.axes > div.bar-block > div.bar-block-left, div.axes > div.bar-block > div.bar-block-right')
.should('have.length', 24)
.each((el) => {
cy.wrap(el).should('have.class', 'bar-block-empty');
});
});
it('hides axes for empty segment filling the entire bar (offset > available data)', () => {
cy.configure({
offset: '50'
});
cy.get('weather-bar')
.shadow()
.find('div.axes > div.bar-block > div.bar-block-left, div.axes > div.bar-block > div.bar-block-right')
.should('have.length', 24)
.each((el) => {
cy.wrap(el).should('have.class', 'bar-block-empty');
});
});
});
});
3 changes: 2 additions & 1 deletion src/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const LABELS = {
'sunny': 'conditions.sunny',
'windy': 'conditions.windy',
'windy-variant': 'conditions.windy',
'exceptional': 'conditions.clear'
'exceptional': 'conditions.clear',
'empty': 'conditions.noData'
};
export const ICONS = {
'clear-night': 'weather-night',
Expand Down
3 changes: 0 additions & 3 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,6 @@ export class HourlyWeatherCardEditor extends ScopedRegistryHost(LitElement) impl
.configValue=${'offset'}
@input=${this._valueChanged}
.type=${'number'}
.min=${0}
.autoValidate=${true}
validationMessage=${localize('errors.must_be_positive_int')}
></mwc-textfield>
<mwc-textfield
label=${localize('editor.label_spacing')}
Expand Down
36 changes: 24 additions & 12 deletions src/hourly-weather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export class HourlyWeatherCard extends LitElement {
const precipitationUnit = state.attributes.precipitation_unit ?? '';
const numSegments = parseInt(config.num_segments ?? config.num_hours ?? '12', 10);
const offset = parseInt(config.offset ?? '0', 10);
const numEmptySegmentsLeading = Math.min(numSegments, Math.max(0, -offset));
const labelSpacing = parseInt(config.label_spacing ?? '2', 10);
const forecastNotAvailable = !forecast || !forecast.length;

Expand All @@ -246,14 +247,6 @@ export class HourlyWeatherCard extends LitElement {
return await this._showError(this.localize('errors.offset_must_be_positive_int', 'offset', 'num_segments'));
}

if (offset < 0) {
return await this._showError(this.localize('errors.offset_must_be_positive_int'));
}

if (!forecastNotAvailable && numSegments > (forecast.length - offset)) {
return await this._showError(this.localize('errors.too_many_segments_requested'));
}

if (labelSpacing < 1) {
// REMARK: Ok, so I'm re-using a localized string here. Probably not the best, but it avoids repeating for no good reason
return await this._showError(this.localize('errors.offset_must_be_positive_int', 'offset', 'label_spacing'));
Expand Down Expand Up @@ -282,11 +275,16 @@ export class HourlyWeatherCard extends LitElement {
</ha-card>`;
}

// Adjust numSegments to only load the required number after the empty segments
const offsetAdjusted = Math.max(0, offset);
const numSegmentsAdjusted = Math.max(0,Math.min(numSegments - numEmptySegmentsLeading, forecast.length - offsetAdjusted));
const numEmptySegmentsTrailing = numSegments - (numSegmentsAdjusted + numEmptySegmentsLeading);

const isForecastDaily = this.isForecastDaily(forecast);
const conditionList = this.getConditionListFromForecast(forecast, numSegments, offset);
const temperatures = this.getTemperatures(forecast, numSegments, offset);
const wind = this.getWind(forecast, numSegments, offset, windSpeedUnit);
const precipitation = this.getPrecipitation(forecast, numSegments, offset, precipitationUnit);
const conditionList = this.getConditionListFromForecast(forecast, numSegmentsAdjusted, offsetAdjusted);
const temperatures = this.getTemperatures(forecast, numSegmentsAdjusted, offsetAdjusted);
const wind = this.getWind(forecast, numSegmentsAdjusted, offsetAdjusted, windSpeedUnit);
const precipitation = this.getPrecipitation(forecast, numSegmentsAdjusted, offsetAdjusted, precipitationUnit);

const colorSettings = this.getColorSettings(config.colors);

Expand Down Expand Up @@ -322,13 +320,18 @@ export class HourlyWeatherCard extends LitElement {
.show_precipitation_probability=${!!config.show_precipitation_probability}
.show_date=${config.show_date}
.label_spacing=${labelSpacing}
.num_empty_segments_leading=${numEmptySegmentsLeading}
.num_empty_segments_trailing=${numEmptySegmentsTrailing}
.labels=${this.labels}></weather-bar>
</div>
</ha-card>
`;
}

private getConditionListFromForecast(forecast: ForecastSegment[], numSegments: number, offset: number): ConditionSpan[] {
if (numSegments < 1) {
return [];
}
let lastCond: string = forecast[offset].condition;
let j = 0;
const res: ConditionSpan[] = [[lastCond, 1]];
Expand All @@ -346,6 +349,9 @@ export class HourlyWeatherCard extends LitElement {
}

private getTemperatures(forecast: ForecastSegment[], numSegments: number, offset: number): SegmentTemperature[] {
if (numSegments < 1) {
return [];
}
const temperatures: SegmentTemperature[] = [];
for (let i = offset; i < numSegments + offset; i++) {
const fs = forecast[i];
Expand All @@ -360,6 +366,9 @@ export class HourlyWeatherCard extends LitElement {
}

private getPrecipitation(forecast: ForecastSegment[], numSegments: number, offset: number, unit: string): SegmentPrecipitation[] {
if (numSegments < 1) {
return [];
}
const precipitation: SegmentPrecipitation[] = [];
for (let i = offset; i < numSegments + offset; i++) {
const fs = forecast[i];
Expand All @@ -384,6 +393,9 @@ export class HourlyWeatherCard extends LitElement {
}

private getWind(forecast: ForecastSegment[], numSegments: number, offset: number, speedUnit: string): SegmentWind[] {
if (numSegments < 1) {
return [];
}
const wind: SegmentWind[] = [];
for (let i = offset; i < numSegments + offset; i++) {
const fs = forecast[i];
Expand Down
3 changes: 2 additions & 1 deletion src/localize/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"snow": "Snow",
"mixedPrecip": "Mixed precip",
"sunny": "Sunny",
"windy": "Windy"
"windy": "Windy",
"noData": "No data"
},
"direction": {
"n": "N",
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface ColorConfig {
'windy'?: ColorDefinition;
'windy-variant'?: ColorDefinition;
'exceptional'?: ColorDefinition;
'empty'?: ColorDefinition;
}

export interface ForecastSegment {
Expand Down
Loading