(core) DocLimits: display days remaining instead of days of grace period

Summary:
Before this change we would always say there are 14 days remaining,
regardless of how many actually are remaining. Let's pass around a
different `dataLimitsInfo` object that also reports the number of days
remaining.

Test Plan: Ensure the test suite passes.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4332
dependabot/npm_and_yarn/axios-1.7.4
Jordi Gutiérrez Hermoso 3 weeks ago
parent 8da89b0a3d
commit 80f8168cab

@ -7,7 +7,7 @@ import {withInfoTooltip} from 'app/client/ui/tooltips';
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars'; import {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingDots, loadingSpinner} from 'app/client/ui2018/loaders';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus} from 'app/common/DocUsage'; import {APPROACHING_LIMIT_RATIO, DataLimitInfo} from 'app/common/DocUsage';
import {Features, isFreePlan} from 'app/common/Features'; import {Features, isFreePlan} from 'app/common/Features';
import {capitalizeFirstWord} from 'app/common/gutil'; import {capitalizeFirstWord} from 'app/common/gutil';
import {canUpgradeOrg} from 'app/common/roles'; import {canUpgradeOrg} from 'app/common/roles';
@ -40,8 +40,8 @@ export class DocumentUsage extends Disposable {
// TODO: Update this whenever the rest of the UI is internationalized. // TODO: Update this whenever the rest of the UI is internationalized.
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US'); private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
private readonly _dataLimitStatus = Computed.create(this, this._currentDocUsage, (_use, usage) => { private readonly _dataLimitInfo = Computed.create(this, this._currentDocUsage, (_use, usage) => {
return usage?.dataLimitStatus ?? null; return usage?.dataLimitInfo;
}); });
private readonly _rowCount = Computed.create(this, this._currentDocUsage, (_use, usage) => { private readonly _rowCount = Computed.create(this, this._currentDocUsage, (_use, usage) => {
@ -158,11 +158,11 @@ export class DocumentUsage extends Disposable {
const org = use(this._currentOrg); const org = use(this._currentOrg);
const product = use(this._currentProduct); const product = use(this._currentProduct);
const features = use(this._currentFeatures); const features = use(this._currentFeatures);
const status = use(this._dataLimitStatus); const usageInfo = use(this._dataLimitInfo);
if (!org || !status) { return null; } if (!org || !usageInfo?.status) { return null; }
return buildMessage([ return buildMessage([
buildLimitStatusMessage(status, features, { buildLimitStatusMessage(usageInfo, features, {
disableRawDataLink: true disableRawDataLink: true
}), }),
(product && isFreePlan(product.name) (product && isFreePlan(product.name)
@ -196,13 +196,14 @@ export class DocumentUsage extends Disposable {
} }
export function buildLimitStatusMessage( export function buildLimitStatusMessage(
status: NonNullable<DataLimitStatus>, usageInfo: NonNullable<DataLimitInfo>,
features?: Features|null, features?: Features|null,
options: { options: {
disableRawDataLink?: boolean; disableRawDataLink?: boolean;
} = {} } = {}
) { ) {
const {disableRawDataLink = false} = options; const {disableRawDataLink = false} = options;
const {status, daysRemaining} = usageInfo;
switch (status) { switch (status) {
case 'approachingLimit': { case 'approachingLimit': {
return [ return [
@ -224,7 +225,7 @@ export function buildLimitStatusMessage(
return [ return [
'Document limits ', 'Document limits ',
disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'), disableRawDataLink ? 'exceeded' : buildRawDataPageLink('exceeded'),
`. In ${gracePeriodDays} days, this document will be read-only.` `. In ${daysRemaining} days, this document will be read-only.`
]; ];
} }
case 'deleteOnly': { case 'deleteOnly': {

@ -1,5 +1,6 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {APPROACHING_LIMIT_RATIO, DataLimitStatus, DocumentUsage, getUsageRatio} from 'app/common/DocUsage'; import {APPROACHING_LIMIT_RATIO, DataLimitInfo, DataLimitStatus,
DocumentUsage, getUsageRatio} from 'app/common/DocUsage';
import {Features} from 'app/common/Features'; import {Features} from 'app/common/Features';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
@ -22,22 +23,24 @@ export interface GetDataLimitStatusParams {
* Given a set of params that includes document usage, current product features, and * Given a set of params that includes document usage, current product features, and
* a grace-period start (if any), returns the data limit status of a document. * a grace-period start (if any), returns the data limit status of a document.
*/ */
export function getDataLimitStatus(params: GetDataLimitStatusParams): DataLimitStatus { export function getDataLimitInfo(params: GetDataLimitStatusParams): DataLimitInfo {
const {docUsage, productFeatures, gracePeriodStart} = params; const {docUsage, productFeatures, gracePeriodStart} = params;
const ratio = getDataLimitRatio(docUsage, productFeatures); const ratio = getDataLimitRatio(docUsage, productFeatures);
if (ratio > 1) { if (ratio > 1) {
const start = gracePeriodStart; const start = gracePeriodStart;
const days = productFeatures?.gracePeriodDays; // In case we forgot to define a grace period, we'll default to two weeks.
if (start && days && moment().diff(moment(start), 'days') >= days) { const days = productFeatures?.gracePeriodDays ?? 14;
return 'deleteOnly'; const daysRemaining = start && days ? days - moment().diff(moment(start), 'days') : NaN;
if (daysRemaining > 0) {
return {status: 'gracePeriod', daysRemaining};
} else { } else {
return 'gracePeriod'; return {status: 'deleteOnly'};
} }
} else if (ratio > APPROACHING_LIMIT_RATIO) { } else if (ratio > APPROACHING_LIMIT_RATIO) {
return 'approachingLimit'; return {status: 'approachingLimit'};
} else {
return null;
} }
return {status: null};
} }
/** /**

@ -10,13 +10,17 @@ export interface RowCounts {
} }
export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null; export type DataLimitStatus = 'approachingLimit' | 'gracePeriod' | 'deleteOnly' | null;
export interface DataLimitInfo {
status: DataLimitStatus;
daysRemaining?: number;
}
type DocUsageOrPending = { type DocUsageOrPending = {
[Metric in keyof Required<DocumentUsage>]: Required<DocumentUsage>[Metric] | 'pending' [Metric in keyof Required<DocumentUsage>]: Required<DocumentUsage>[Metric] | 'pending'
} }
export interface DocUsageSummary extends DocUsageOrPending { export interface DocUsageSummary extends DocUsageOrPending {
dataLimitStatus: DataLimitStatus; dataLimitInfo: DataLimitInfo;
} }
// Count of non-removed documents in an org, grouped by data limit status. // Count of non-removed documents in an org, grouped by data limit status.
@ -27,7 +31,7 @@ type FilteredDocUsage = {
} }
export interface FilteredDocUsageSummary extends FilteredDocUsage { export interface FilteredDocUsageSummary extends FilteredDocUsage {
dataLimitStatus: DataLimitStatus; dataLimitInfo: DataLimitInfo;
} }
// Ratio of usage at which we start telling users that they're approaching limits. // Ratio of usage at which we start telling users that they're approaching limits.

@ -1,7 +1,7 @@
import {ShareInfo} from 'app/common/ActiveDocAPI'; import {ShareInfo} from 'app/common/ActiveDocAPI';
import {ApiError, LimitType} from 'app/common/ApiError'; import {ApiError, LimitType} from 'app/common/ApiError';
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate'; import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
import {getDataLimitStatus} from 'app/common/DocLimits'; import {getDataLimitInfo} from 'app/common/DocLimits';
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage'; import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
import {normalizeEmail} from 'app/common/emails'; import {normalizeEmail} from 'app/common/emails';
import {ANONYMOUS_PLAN, canAddOrgMembers, Features} from 'app/common/Features'; import {ANONYMOUS_PLAN, canAddOrgMembers, Features} from 'app/common/Features';
@ -764,7 +764,7 @@ export class HomeDBManager extends EventEmitter {
// Return an aggregate count of documents, grouped by data limit status. // Return an aggregate count of documents, grouped by data limit status.
const summary = createEmptyOrgUsageSummary(); const summary = createEmptyOrgUsageSummary();
for (const {usage: docUsage, gracePeriodStart} of docs) { for (const {usage: docUsage, gracePeriodStart} of docs) {
const dataLimitStatus = getDataLimitStatus({docUsage, gracePeriodStart, productFeatures}); const dataLimitStatus = getDataLimitInfo({docUsage, gracePeriodStart, productFeatures}).status;
if (dataLimitStatus) { summary[dataLimitStatus] += 1; } if (dataLimitStatus) { summary[dataLimitStatus] += 1; }
} }
return summary; return summary;

@ -50,12 +50,12 @@ import {
UserAction UserAction
} from 'app/common/DocActions'; } from 'app/common/DocActions';
import {DocData} from 'app/common/DocData'; import {DocData} from 'app/common/DocData';
import {getDataLimitRatio, getDataLimitStatus, getSeverity, LimitExceededError} from 'app/common/DocLimits'; import {getDataLimitInfo, getDataLimitRatio, getSeverity, LimitExceededError} from 'app/common/DocLimits';
import {DocSnapshots} from 'app/common/DocSnapshot'; import {DocSnapshots} from 'app/common/DocSnapshot';
import {DocumentSettings} from 'app/common/DocumentSettings'; import {DocumentSettings} from 'app/common/DocumentSettings';
import { import {
APPROACHING_LIMIT_RATIO, APPROACHING_LIMIT_RATIO,
DataLimitStatus, DataLimitInfo,
DocumentUsage, DocumentUsage,
DocUsageSummary, DocUsageSummary,
FilteredDocUsageSummary, FilteredDocUsageSummary,
@ -421,8 +421,8 @@ export class ActiveDoc extends EventEmitter {
return getDataLimitRatio(this._docUsage, this._product?.features); return getDataLimitRatio(this._docUsage, this._product?.features);
} }
public get dataLimitStatus(): DataLimitStatus { public get dataLimitInfo(): DataLimitInfo {
return getDataLimitStatus({ return getDataLimitInfo({
docUsage: this._docUsage, docUsage: this._docUsage,
productFeatures: this._product?.features, productFeatures: this._product?.features,
gracePeriodStart: this._gracePeriodStart, gracePeriodStart: this._gracePeriodStart,
@ -431,7 +431,7 @@ export class ActiveDoc extends EventEmitter {
public getDocUsageSummary(): DocUsageSummary { public getDocUsageSummary(): DocUsageSummary {
return { return {
dataLimitStatus: this.dataLimitStatus, dataLimitInfo: this.dataLimitInfo,
rowCount: this._docUsage?.rowCount ?? 'pending', rowCount: this._docUsage?.rowCount ?? 'pending',
dataSizeBytes: this._docUsage?.dataSizeBytes ?? 'pending', dataSizeBytes: this._docUsage?.dataSizeBytes ?? 'pending',
attachmentsSizeBytes: this._docUsage?.attachmentsSizeBytes ?? 'pending', attachmentsSizeBytes: this._docUsage?.attachmentsSizeBytes ?? 'pending',
@ -2176,7 +2176,7 @@ export class ActiveDoc extends EventEmitter {
await this.waitForInitialization(); await this.waitForInitialization();
if ( if (
this.dataLimitStatus === "deleteOnly" && this.dataLimitInfo.status === "deleteOnly" &&
!actions.every(action => [ !actions.every(action => [
'RemoveTable', 'RemoveColumn', 'RemoveRecord', 'BulkRemoveRecord', 'RemoveTable', 'RemoveColumn', 'RemoveRecord', 'BulkRemoveRecord',
'RemoveViewSection', 'RemoveView', 'ApplyUndoActions', 'RespondToRequests', 'RemoveViewSection', 'RemoveView', 'ApplyUndoActions', 'RespondToRequests',
@ -2253,13 +2253,13 @@ export class ActiveDoc extends EventEmitter {
*/ */
private async _updateDocUsage(usage: Partial<DocumentUsage>, options: UpdateUsageOptions = {}) { private async _updateDocUsage(usage: Partial<DocumentUsage>, options: UpdateUsageOptions = {}) {
const {syncUsageToDatabase = true, broadcastUsageToClients = true} = options; const {syncUsageToDatabase = true, broadcastUsageToClients = true} = options;
const oldStatus = this.dataLimitStatus; const oldStatus = this.dataLimitInfo.status;
this._docUsage = {...(this._docUsage || {}), ...usage}; this._docUsage = {...(this._docUsage || {}), ...usage};
if (syncUsageToDatabase) { if (syncUsageToDatabase) {
/* If status decreased, we'll update usage in the database with minimal delay, so site usage /* If status decreased, we'll update usage in the database with minimal delay, so site usage
* banners show up-to-date statistics. If status increased or stayed the same, we'll schedule * banners show up-to-date statistics. If status increased or stayed the same, we'll schedule
* a delayed update, since it's less critical for banners to update immediately. */ * a delayed update, since it's less critical for banners to update immediately. */
const didStatusDecrease = getSeverity(this.dataLimitStatus) < getSeverity(oldStatus); const didStatusDecrease = getSeverity(this.dataLimitInfo.status) < getSeverity(oldStatus);
this._syncDocUsageToDatabase(didStatusDecrease); this._syncDocUsageToDatabase(didStatusDecrease);
} }
if (broadcastUsageToClients) { if (broadcastUsageToClients) {

@ -792,7 +792,7 @@ export class GranularAccess implements GranularAccessForBundle {
} }
const role = options.role ?? await this.getNominalAccess(docSession); const role = options.role ?? await this.getNominalAccess(docSession);
const hasEditRole = canEdit(role); const hasEditRole = canEdit(role);
if (!hasEditRole) { result.dataLimitStatus = null; } if (!hasEditRole) { result.dataLimitInfo.status = null; }
const hasFullReadAccess = await this.canReadEverything(docSession); const hasFullReadAccess = await this.canReadEverything(docSession);
if (!hasEditRole || !hasFullReadAccess) { if (!hasEditRole || !hasFullReadAccess) {
result.rowCount = 'hidden'; result.rowCount = 'hidden';

@ -2212,6 +2212,7 @@ describe('ApiServer', function() {
await server.dbManager.setDocsMetadata({[id]: {usage}}); await server.dbManager.setDocsMetadata({[id]: {usage}});
} }
await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 1], new Date(2000, 1, 1)); await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 1], new Date(2000, 1, 1));
await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 2], new Date());
// Check that what's reported by /usage is accurate. // Check that what's reported by /usage is accurate.
await assertOrgUsage(freeTeamOrgId, chimpy, { await assertOrgUsage(freeTeamOrgId, chimpy, {
@ -2238,6 +2239,7 @@ describe('ApiServer', function() {
dataSizeBytes: 999999999, dataSizeBytes: 999999999,
attachmentsSizeBytes: 999999999, attachmentsSizeBytes: 999999999,
}}}); }}});
await server.dbManager.setDocGracePeriodStart(docId, new Date());
// Check that /usage includes that document in the count. // Check that /usage includes that document in the count.
await assertOrgUsage(freeTeamOrgId, chimpy, { await assertOrgUsage(freeTeamOrgId, chimpy, {

Loading…
Cancel
Save