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

Keep features up-to-date in the Feature Detail page #4481

Merged
merged 3 commits into from
Oct 30, 2024
Merged
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
112 changes: 111 additions & 1 deletion client-src/elements/chromedash-feature-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
FeatureLink,
FeatureNotFoundError,
User,
StageDict,
} from '../js-src/cs-client.js';
import './chromedash-feature-detail';
import {DETAILS_STYLES} from './chromedash-feature-detail';
import './chromedash-feature-highlights.js';
import {GateDict} from './chromedash-gate-chip.js';
import {Process, ProgressItem} from './chromedash-gate-column.js';
import {showToastMessage} from './utils.js';
import {showToastMessage, isVerifiedWithinGracePeriod} from './utils.js';
import {STAGE_TYPES_SHIPPING} from './form-field-enums';

const INACTIVE_STATES = ['No longer pursuing', 'Deprecated', 'Removed'];
declare var ga: Function;
Expand Down Expand Up @@ -109,6 +111,13 @@ export class ChromedashFeaturePage extends LitElement {
starred = false;
@state()
loading = true;
@state()
isUpcoming = false;
@state()
currentDate: number = Date.now();
@state()
// The closest milestone shipping date as an ISO string.
closestShippingDate: string = '';

connectedCallback() {
super.connectedCallback();
Expand All @@ -119,6 +128,60 @@ export class ChromedashFeaturePage extends LitElement {
return this.feature && Object.keys(this.feature).length !== 0;
}

/**
* Determine if this feature is upcoming - scheduled to ship
* within two milestones, and find the closest shipping date
* for that milestone.*/
calcUpcoming(channels, stages: Array<StageDict>) {
const latestStableVersion = channels['stable']?.version;
if (!latestStableVersion || !stages) {
return;
}

const shippingMilestones = new Set<number | undefined>();
// Get milestones from all shipping stages, STAGE_TYPES_SHIPPING.
for (const stage of stages) {
if (STAGE_TYPES_SHIPPING.has(stage.stage_type)) {
shippingMilestones.add(stage.desktop_first);
shippingMilestones.add(stage.android_first);
shippingMilestones.add(stage.ios_first);
shippingMilestones.add(stage.webview_first);
}
}
// Check if this feature is shipped within two milestones.
let foundMilestone = 0;
if (shippingMilestones.has(latestStableVersion + 1)) {
foundMilestone = latestStableVersion + 1;
this.isUpcoming = true;
} else if (shippingMilestones.has(latestStableVersion + 2)) {
foundMilestone = latestStableVersion + 2;
this.isUpcoming = true;
}

if (this.isUpcoming) {
Object.keys(channels).forEach(key => {
if (channels[key].version === foundMilestone) {
this.closestShippingDate = channels[key].final_beta;
}
});
}
}

/**
* A feature is outdated if it is scheduled to ship in the next 2 milestones,
* and its accurate_as_of date is at least 4 weeks ago.*/
isFeatureOutdated(): boolean {
if (!this.isUpcoming) {
return false;
KyleJu marked this conversation as resolved.
Show resolved Hide resolved
}

const isVerified = isVerifiedWithinGracePeriod(
this.feature.accurate_as_of,
this.currentDate
);
return !isVerified;
}

fetchData() {
this.loading = true;
Promise.all([
Expand All @@ -128,6 +191,7 @@ export class ChromedashFeaturePage extends LitElement {
window.csClient.getFeatureProcess(this.featureId),
window.csClient.getStars(),
window.csClient.getFeatureProgress(this.featureId),
window.csClient.getChannels(),
])
.then(
([
Expand All @@ -137,6 +201,7 @@ export class ChromedashFeaturePage extends LitElement {
process,
starredFeatures,
progress,
channels,
]) => {
this.feature = feature;
this.gates = gatesRes.gates;
Expand All @@ -149,6 +214,7 @@ export class ChromedashFeaturePage extends LitElement {
if (this.feature.name) {
document.title = `${this.feature.name} - ${this.appTitle}`;
}
this.calcUpcoming(channels, feature.stages);
this.loading = false;
}
)
Expand Down Expand Up @@ -420,6 +486,50 @@ export class ChromedashFeaturePage extends LitElement {
</div>
`);
}
if (this.isFeatureOutdated()) {
if (this.userCanEdit()) {
warnings.push(html`
<div class="warning layout horizontal center">
<span class="tooltip" id="outdated-icon" title="Feature outdated ">
<iron-icon icon="chromestatus:error" data-tooltip></iron-icon>
</span>
<span>
Your feature hasn't been verified as accurate since${' '}
<sl-relative-time
date=${this.feature.accurate_as_of}
></sl-relative-time
>, but it is scheduled to ship${' '}
<sl-relative-time
date=${this.closestShippingDate}
></sl-relative-time
>. Please
<a href="/guide/verify_accuracy/${this.featureId}"
>verify that your feature is accurate</a
>.
</span>
</div>
`);
} else {
warnings.push(html`
<div class="warning layout horizontal center">
<span class="tooltip" id="outdated-icon" title="Feature outdated ">
<iron-icon icon="chromestatus:error" data-tooltip></iron-icon>
</span>
<span>
This feature hasn't been verified as accurate since${' '}
<sl-relative-time
date=${this.feature.accurate_as_of}
></sl-relative-time
>, but it is scheduled to ship${' '}
<sl-relative-time
date=${this.closestShippingDate}
></sl-relative-time
>.
</span>
</div>
`);
}
}
return warnings;
}

Expand Down
109 changes: 107 additions & 2 deletions client-src/elements/chromedash-feature-page_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('chromedash-feature-page', () => {
'Spec link': 'fake spec link',
'Web developer signals': 'True',
});
const channelsPromise = Promise.resolve({
const channels = {
canary_asan: {
version: 81,
earliest_beta: '2020-02-13T00:00:00',
Expand All @@ -59,13 +59,15 @@ describe('chromedash-feature-page', () => {
version: 80,
earliest_beta: '2020-02-13T00:00:00',
mstone: 'fake milestone number',
final_beta: '2020-03-13T00:00:00',
},
stable: {
version: 79,
earliest_beta: '2020-02-13T00:00:00',
mstone: 'fake milestone number',
},
});
};
const channelsPromise = Promise.resolve(channels);
const validFeaturePromise = Promise.resolve({
id: 123456,
name: 'feature one',
Expand Down Expand Up @@ -103,6 +105,12 @@ describe('chromedash-feature-page', () => {
stage_type: 120,
intent_stage: 2,
},
{
id: 3,
stage_type: 160,
intent_stage: 3,
desktop_first: 80,
},
],
});

Expand Down Expand Up @@ -314,4 +322,101 @@ describe('chromedash-feature-page', () => {
// But it does still include webdev views.
assert.include(consensusSection.innerHTML, 'fake webdev view text');
});

it('calcUpcoming() tests', async () => {
const featureId = 123456;
const contextLink = '/features';
const feature: any = structuredClone(await validFeaturePromise);
const component: ChromedashFeaturePage =
await fixture<ChromedashFeaturePage>(
html`<chromedash-feature-page
.user=${user}
.featureId=${featureId}
.contextLink=${contextLink}
>
</chromedash-feature-page>`
);
assert.exists(component);

component.calcUpcoming({}, feature.stages);
assert.isFalse(component.isUpcoming);
assert.equal(component.closestShippingDate, '');

component.calcUpcoming(channels, []);
assert.isFalse(component.isUpcoming);
assert.equal(component.closestShippingDate, '');

// No shipping milestones.
let stages: any = structuredClone(feature.stages);
stages[2].stage_type = 130;
component.calcUpcoming(channels, stages);
assert.isFalse(component.isUpcoming);
assert.equal(component.closestShippingDate, '');

// No upcoming shipping milestones.
stages = structuredClone(feature.stages);
stages[2].desktop_first = 20;
component.calcUpcoming(channels, stages);
assert.isFalse(component.isUpcoming);
assert.equal(component.closestShippingDate, '');

component.calcUpcoming(channels, feature.stages);
assert.isTrue(component.isUpcoming);
assert.equal(component.closestShippingDate, '2020-03-13T00:00:00');
});

it('isFeatureOutdated() tests', async () => {
const featureId = 123456;
const contextLink = '/features';
const feature: any = structuredClone(await validFeaturePromise);
feature.accurate_as_of = '2024-08-28 21:51:34.22386';
window.csClient.getFeature
.withArgs(featureId)
.returns(Promise.resolve(feature));
const component: ChromedashFeaturePage =
await fixture<ChromedashFeaturePage>(
html`<chromedash-feature-page
.user=${user}
.featureId=${featureId}
.contextLink=${contextLink}
>
</chromedash-feature-page>`
);
component.currentDate = new Date('2024-10-23').getTime();
assert.exists(component);

component.calcUpcoming(channels, feature.stages);
assert.isTrue(component.isUpcoming);
assert.equal(component.closestShippingDate, '2020-03-13T00:00:00');
assert.isTrue(component.isFeatureOutdated());

// accurate_as_of is not outdated and within the 4-week grace period.
component.currentDate = new Date('2024-09-18').getTime();
assert.isFalse(component.isFeatureOutdated());
});

it('render the oudated warning when outdated', async () => {
const featureId = 123456;
const contextLink = '/features';
const feature: any = structuredClone(await validFeaturePromise);
feature.accurate_as_of = '2024-08-28 21:51:34.22386';
window.csClient.getFeature
.withArgs(featureId)
.returns(Promise.resolve(feature));
const component: ChromedashFeaturePage =
await fixture<ChromedashFeaturePage>(
html`<chromedash-feature-page
.user=${user}
.featureId=${featureId}
.contextLink=${contextLink}
>
</chromedash-feature-page>`
);
component.currentDate = new Date('2024-10-23').getTime();
assert.exists(component);

component.calcUpcoming(channels, feature.stages);
const oudated = component.shadowRoot!.querySelector('#outdated-icon');
assert.exists(oudated);
});
});
27 changes: 27 additions & 0 deletions client-src/elements/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ let toastEl;
// We assume that a small enough window width implies a mobile device.
const NARROW_WINDOW_MAX_WIDTH = 700;

// Represent a 4-week period in milliseconds. This grace period needs
// to be consistent with ACCURACY_GRACE_PERIOD in internals/reminders.py.
const ACCURACY_GRACE_PERIOD = 4 * 7 * 24 * 60 * 60 * 1000;

export const IS_MOBILE = (() => {
const width =
window.innerWidth ||
Expand Down Expand Up @@ -652,3 +656,26 @@ export function extensionMilestoneIsValid(value, currentMilestone) {
// End milestone should not be in the past.
return parseInt(currentMilestone) <= intValue;
}

/**
* Check if feature.accurate_as_of is verified, within the four-week
* grace period to currentDate.
*
* @param accurateAsOf The accurate_as_of date as an ISO string.
* @param currentDate The current date in milliseconds.
*/
export function isVerifiedWithinGracePeriod(
accurateAsOf: string | undefined,
currentDate: number
) {
if (!accurateAsOf) {
return false;
}

const accurateDate = Date.parse(accurateAsOf);
if (accurateDate + ACCURACY_GRACE_PERIOD < currentDate) {
return false;
}

return true;
}
2 changes: 2 additions & 0 deletions internals/reminders.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ def changes_after_sending_notifications(
class FeatureAccuracyHandler(AbstractReminderHandler):
"""Periodically remind owners to verify the accuracy of their entries."""

# This grace period needs to be consistent with
# ACCURACY_GRACE_PERIOD in client-src/elements/utils.ts.
ACCURACY_GRACE_PERIOD = timedelta(weeks=4)
SUBJECT_FORMAT = '[Action requested] Update %s'
EMAIL_TEMPLATE_PATH = 'accuracy_notice_email.html'
Expand Down
Loading