diff --git a/packages/client/src/components/GrantsTable.vue b/packages/client/src/components/GrantsTable.vue index cd7bd6ba7..9ed79cfd0 100644 --- a/packages/client/src/components/GrantsTable.vue +++ b/packages/client/src/components/GrantsTable.vue @@ -279,6 +279,29 @@ export default { txt.innerHTML = t; return txt.value; }; + const generateCloseDate = (date, status, closeDateExplanation) => { + const formattedDate = new Date(date).toLocaleDateString('en-US', { timeZone: 'UTC' }); + const dateExists = date && date !== '2100-01-01'; + if (!dateExists) { + return closeDateExplanation ? 'See details' : 'Not yet issued'; + } + if (status === 'forecasted') { + return `est. ${formattedDate}`; + } + + return formattedDate; + }; + const generateOpenDate = (date, status) => { + const formattedDate = new Date(date).toLocaleDateString('en-US', { timeZone: 'UTC' }); + const dateExists = date && date !== '2100-01-01'; + if (!dateExists) { + return 'Not yet issued'; + } + if (status === 'forecasted') { + return `est. ${formattedDate}`; + } + return formattedDate; + }; return this.grants.map((grant) => ({ ...grant, title: generateTitle(grant.title), @@ -288,10 +311,10 @@ export default { viewed_by: grant.viewed_by_agencies .map((v) => v.agency_abbreviation) .join(', '), - status: grant.opportunity_status, + status: titleize(grant.opportunity_status), award_ceiling: grant.award_ceiling, - open_date: new Date(grant.open_date).toLocaleDateString('en-US', { timeZone: 'UTC' }), - close_date: new Date(grant.close_date).toLocaleDateString('en-US', { timeZone: 'UTC' }), + open_date: generateOpenDate(grant.open_date, grant.opportunity_status?.toLowerCase()), + close_date: generateCloseDate(grant.close_date, grant.opportunity_status?.toLowerCase(), grant.close_date_explanation), _cellVariants: (() => { const daysUntilClose = daysUntil(grant.close_date); if (daysUntilClose <= dangerThreshold) { diff --git a/packages/client/src/components/Modals/SearchPanel.vue b/packages/client/src/components/Modals/SearchPanel.vue index e993b7eb6..46c0ebd4a 100644 --- a/packages/client/src/components/Modals/SearchPanel.vue +++ b/packages/client/src/components/Modals/SearchPanel.vue @@ -269,7 +269,7 @@ const defaultCriteria = { includeKeywords: null, excludeKeywords: null, opportunityNumber: null, - opportunityStatuses: ['posted'], + opportunityStatuses: ['forecasted', 'posted'], fundingTypes: null, agency: null, bill: null, @@ -310,6 +310,7 @@ export default { { code: 'O', name: 'Other' }, ], opportunityStatusOptions: [ + { text: 'Forecasted', value: 'forecasted' }, { text: 'Posted', value: 'posted' }, // b-form-checkbox-group doesn't handle multiple values well 'archived' is added // whenever 'closed' is checked, but as post processing step. See apply() diff --git a/packages/client/src/store/modules/grants.js b/packages/client/src/store/modules/grants.js index f9042424c..45a890a84 100644 --- a/packages/client/src/store/modules/grants.js +++ b/packages/client/src/store/modules/grants.js @@ -68,8 +68,8 @@ function buildGrantsNextQuery({ filters, ordering, pagination }) { criteria.fundingActivityCategories = criteria.fundingActivityCategories?.map((c) => c.code); if (!criteria.opportunityStatuses || criteria.opportunityStatuses.length === 0) { - // by default, only show posted opportunities - criteria.opportunityStatuses = ['posted']; + // by default, only show forecasted and posted opportunities + criteria.opportunityStatuses = ['forecasted', 'posted']; } const paginationQuery = Object.entries(pagination) // filter out undefined and nulls since api expects parameters not present as undefined diff --git a/packages/client/src/views/GrantDetailsView.vue b/packages/client/src/views/GrantDetailsView.vue index c15317130..d4108bb1f 100644 --- a/packages/client/src/views/GrantDetailsView.vue +++ b/packages/client/src/views/GrantDetailsView.vue @@ -103,7 +103,8 @@ hover > @@ -223,7 +224,8 @@ import GrantActivity from '@/components/GrantActivity.vue'; const HEADER = '__HEADER__'; const FAR_FUTURE_CLOSE_DATE = '2100-01-01'; -const NOT_AVAILABLE_TEXT = 'Not available'; +const NOT_YET_ISSUED_TEXT = 'Not yet issued'; +const FORECASTED = 'forecasted'; export default { components: { @@ -282,11 +284,13 @@ export default { value: this.currentGrant.grant_number, }, { name: 'Open Date', - value: this.formatDate(this.currentGrant.open_date), + value: this.openDateDisplay, + displayEstimatedText: this.displayEstimatedOpenDateText, }, { name: 'Close Date', value: this.closeDateDisplay, displayMuted: this.closeDateDisplayMuted, + displayEstimatedText: this.displayEstimatedCloseDateText, }, { name: 'Grant ID', value: this.currentGrant.grant_id, @@ -314,10 +318,23 @@ export default { }, ]; }, + openDateDisplay() { + if (!this.currentGrant.open_date || this.currentGrant.open_date === FAR_FUTURE_CLOSE_DATE) { + return NOT_YET_ISSUED_TEXT; + } + if (this.currentGrant.opportunity_status === FORECASTED) { + // check for date validity here and in closeDateDisplay + return `${this.formatDate(this.currentGrant.open_date)}`; + } + return this.formatDate(this.currentGrant.open_date); + }, closeDateDisplay() { // If we have an explainer text instead of a real close date, display that instead - if (this.currentGrant.close_date === FAR_FUTURE_CLOSE_DATE) { - return this.currentGrant.close_date_explanation ?? NOT_AVAILABLE_TEXT; + if (!this.currentGrant.close_date || this.currentGrant.close_date === FAR_FUTURE_CLOSE_DATE) { + return this.currentGrant.close_date_explanation ?? NOT_YET_ISSUED_TEXT; + } + if (this.currentGrant.opportunity_status === FORECASTED) { + return `${this.formatDate(this.currentGrant.close_date)}`; } return this.formatDate(this.currentGrant.close_date); }, @@ -350,6 +367,12 @@ export default { statusSubmitButtonDisabled() { return this.selectedInterestedCode === null; }, + displayEstimatedCloseDateText() { + return this.currentGrant.close_date && this.currentGrant.opportunity_status === 'forecasted'; + }, + displayEstimatedOpenDateText() { + return this.currentGrant.open_date && this.currentGrant.opportunity_status === 'forecasted'; + }, }, watch: { async currentGrant() { diff --git a/packages/server/src/db/index.js b/packages/server/src/db/index.js index b0cff8338..29a105c5c 100755 --- a/packages/server/src/db/index.js +++ b/packages/server/src/db/index.js @@ -548,6 +548,7 @@ function grantsQuery(queryBuilder, filters, agencyId, orderingParams, pagination CASE WHEN grants.archive_date <= now() THEN 'archived' WHEN grants.close_date <= now() THEN 'closed' + WHEN grants.open_date > now() OR grants.opportunity_status = 'forecasted' THEN 'forecasted' ELSE 'posted' END IN (${Array(filters.opportunityStatuses.length).fill('?').join(',')})`, filters.opportunityStatuses); } @@ -730,6 +731,7 @@ async function getGrantsNew(filters, paginationParams, orderingParams, tenantId, 'grants.cfda_list', 'grants.open_date', 'grants.close_date', + 'grants.close_date_explanation', 'grants.archive_date', 'grants.reviewer_name', 'grants.opportunity_category', @@ -746,11 +748,13 @@ async function getGrantsNew(filters, paginationParams, orderingParams, tenantId, 'grants.funding_instrument_codes', 'grants.bill', 'grants.funding_activity_category_codes', + 'grants.opportunity_status', ]) .select(knex.raw(` CASE WHEN grants.archive_date <= now() THEN 'archived' WHEN grants.close_date <= now() THEN 'closed' + WHEN grants.open_date > now() OR grants.opportunity_status = 'forecasted' THEN 'forecasted' ELSE 'posted' END as opportunity_status `)) diff --git a/packages/server/src/routes/grants.js b/packages/server/src/routes/grants.js index d7577e00e..7836c7b8d 100755 --- a/packages/server/src/routes/grants.js +++ b/packages/server/src/routes/grants.js @@ -47,6 +47,7 @@ router.get('/', requireUser, async (req, res) => { }); function criteriaToFiltersObj(criteria, agencyId) { + // this function makes request to populate grants table const filters = criteria || {}; const postedWithinOptions = { 'All Time': 0, 'One Week': 7, '30 Days': 30, '60 Days': 60, @@ -71,6 +72,7 @@ function criteriaToFiltersObj(criteria, agencyId) { } router.get('/next', requireUser, async (req, res) => { + // api call to populate table const { user } = req.session; let orderingParams; @@ -131,20 +133,29 @@ router.get('/exportCSVNew', requireUser, async (req, res) => { ); // Generate CSV - const formattedData = data.map((grant) => ({ - ...grant, - funding_activity_categories: grant.funding_activity_categories.join('|'), - interested_agencies: grant.interested_agencies - .map((v) => v.agency_abbreviation) - .join(', '), - viewed_by: grant.viewed_by_agencies - .map((v) => v.agency_abbreviation) - .join(', '), - open_date: new Date(grant.open_date).toLocaleDateString('en-US', { timeZone: 'UTC' }), - close_date: new Date(grant.close_date).toLocaleDateString('en-US', { timeZone: 'UTC' }), - url: `https://www.grants.gov/search-results-detail/${grant.grant_id}`, - })); + const formattedData = data.map((grant) => { + let openDate = new Date(grant.open_date).toLocaleDateString('en-US', { timeZone: 'UTC' }); + let closeDate = new Date(grant.close_date).toLocaleDateString('en-US', { timeZone: 'UTC' }); + if (grant.opportunity_status === 'forecasted') { + openDate = grant.open_date ? `est. ${openDate}` : 'not yet issued'; + closeDate = grant.close_date ? `est ${closeDate}` : grant.close_date_explanation || 'not yet issued'; + } + + return ({ + ...grant, + funding_activity_categories: grant.funding_activity_categories.join('|'), + interested_agencies: grant.interested_agencies + .map((v) => v.agency_abbreviation) + .join(', '), + viewed_by: grant.viewed_by_agencies + .map((v) => v.agency_abbreviation) + .join(', '), + open_date: openDate, + close_date: closeDate, + url: `https://www.grants.gov/search-results-detail/${grant.grant_id}`, + }); + }); if (data.length === 0) { // If there are 0 rows, csv-stringify won't even emit the header, resulting in a totally // empty file, which is confusing. This adds a single empty row below the header.