Show "Next Hourglass" Month (#13860)

* Show "Next Hourglass" Month

* fix lint

* lint,

* lint

* lint..

* linting bad

* ui fixes

* remove additional margin

* show next hourglass date to debug further

* WIP tests - maybe broken logic

* flex:1 doesn't work - so stats columns now at 33% width

* fix(cron): lint and short circuit

* refactor logic

* update test dates using timezone

* also check for the timezone date

* fix timezone for tests

* fixing the test dates?

* fixing the test dates?

* change nextHourglass logic + update gem cap label / value

* fix lint

* dont add gemsBought to it

* remove tooltip

Co-authored-by: SabreCat <sabe@habitica.com>
This commit is contained in:
negue
2022-04-06 23:30:13 +02:00
committed by GitHub
parent 05cf0cb50d
commit 8cb8411cc6
9 changed files with 282 additions and 72 deletions

View File

@@ -1,6 +1,6 @@
import moment from 'moment'; import moment from 'moment';
import { startOfDay, daysSince } from '../../../website/common/script/cron'; import { startOfDay, daysSince, getPlanContext } from '../../../website/common/script/cron';
function localMoment (timeString, utcOffset) { function localMoment (timeString, utcOffset) {
return moment(timeString).utcOffset(utcOffset, true); return moment(timeString).utcOffset(utcOffset, true);
@@ -181,4 +181,63 @@ describe('cron utility functions', () => {
expect(result).to.equal(0); expect(result).to.equal(0);
}); });
}); });
describe('getPlanContext', () => {
const now = new Date(2022, 5, 1);
function baseUserData (count, offset, planId) {
return {
purchased: {
plan: {
consecutive: {
count,
offset,
gemCapExtra: 25,
trinkets: 19,
},
quantity: 1,
extraMonths: 0,
gemsBought: 0,
owner: '116b4133-8fb7-43f2-b0de-706621a8c9d8',
nextBillingDate: null,
nextPaymentProcessing: null,
planId,
customerId: 'group-plan',
dateUpdated: '2022-05-10T03:00:00.144+01:00',
paymentMethod: 'Group Plan',
dateTerminated: null,
lastBillingDate: null,
dateCreated: '2017-02-10T19:00:00.355+01:00',
},
},
};
}
it('offset 0, next date in 3 months', () => {
const user = baseUserData(60, 0, 'group_plan_auto');
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-08-10T02:00:00.144Z');
});
it('offset 1, next date in 1 months', () => {
const user = baseUserData(60, 1, 'group_plan_auto');
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-06-10T02:00:00.144Z');
});
it('offset 2, next date in 2 months - with any plan', () => {
const user = baseUserData(60, 2, 'basic_3mo');
const planContext = getPlanContext(user, now);
expect(planContext.nextHourglassDate)
.to.be.sameMoment('2022-07-10T02:00:00.144Z');
});
});
}); });

View File

@@ -1,4 +1,5 @@
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
import getters from '@/store/getters';
export const userStyles = { export const userStyles = {
contributor: { contributor: {
@@ -82,3 +83,25 @@ export const userStyles = {
classSelected: true, classSelected: true,
}, },
}; };
export function mockStore ({
userData,
...state
}) {
return {
getters,
dispatch: () => {
},
watch: () => {
},
state: {
user: {
data: {
...userData,
},
},
...state,
},
};
}

View File

@@ -7,7 +7,7 @@ import { setup as setupPayments } from '@/libs/payments';
setupPayments(); setupPayments();
storiesOf('Payments Buttons', module) storiesOf('Subscriptions/Payments Buttons', module)
.add('simple', () => ({ .add('simple', () => ({
components: { PaymentsButtonsList }, components: { PaymentsButtonsList },
template: ` template: `

View File

@@ -0,0 +1,37 @@
/* eslint-disable import/no-extraneous-dependencies */
import { storiesOf } from '@storybook/vue';
import Subscription from './subscription.vue';
import { mockStore } from '../../../config/storybook/mock.data';
storiesOf('Subscriptions/Detail Page', module)
.add('subscribed', () => ({
components: { Subscription },
template: `
<div style="position: absolute; margin: 20px">
<subscription ></subscription>
</div>
`,
data () {
return {
};
},
store: mockStore({
userData: {
purchased: {
plan: {
customerId: 'customer-id',
planId: 'plan-id',
subscriptionId: 'sub-id',
gemsBought: 22,
dateUpdated: new Date(2021, 0, 15),
consecutive: {
count: 2,
gemCapExtra: 4,
offset: 2,
},
},
},
},
}),
}));

View File

@@ -93,7 +93,7 @@
<div class="subscribe-card mx-auto"> <div class="subscribe-card mx-auto">
<div <div
v-if="hasSubscription && !hasCanceledSubscription" v-if="hasSubscription && !hasCanceledSubscription"
class="d-flex flex-column align-items-center my-4" class="d-flex flex-column align-items-center"
> >
<div class="round-container bg-green-10 d-flex align-items-center justify-content-center"> <div class="round-container bg-green-10 d-flex align-items-center justify-content-center">
<div <div
@@ -102,7 +102,7 @@
v-html="icons.checkmarkIcon" v-html="icons.checkmarkIcon"
></div> ></div>
</div> </div>
<h2 class="green-10 mx-auto"> <h2 class="green-10 mx-auto mb-75">
{{ $t('youAreSubscribed') }} {{ $t('youAreSubscribed') }}
</h2> </h2>
<div <div
@@ -180,17 +180,17 @@
</div> </div>
<div <div
v-if="hasSubscription" v-if="hasSubscription"
class="bg-gray-700 p-2 text-center" class="bg-gray-700 py-3 mt-4 mb-3 text-center"
> >
<div class="header-mini mb-3"> <div class="header-mini mb-3">
{{ $t('subscriptionStats') }} {{ $t('subscriptionStats') }}
</div> </div>
<div class="d-flex justify-content-around"> <div class="d-flex">
<div class="ml-4 mr-3"> <div class="stat-column">
<div class="d-flex justify-content-center align-items-center"> <div class="d-flex justify-content-center align-items-center">
<div <div
v-once v-once
class="svg-icon svg-calendar mr-2" class="svg-icon svg-calendar mr-1"
v-html="icons.calendarIcon" v-html="icons.calendarIcon"
> >
</div> </div>
@@ -204,49 +204,53 @@
</div> </div>
</div> </div>
<div class="stats-spacer"></div> <div class="stats-spacer"></div>
<div> <div class="stat-column">
<div class="d-flex justify-content-center align-items-center"> <div class="d-flex justify-content-center align-items-center">
<div <div
v-once v-once
class="svg-icon svg-gem mr-2" class="svg-icon svg-gem mr-1"
v-html="icons.gemIcon" v-html="icons.gemIcon"
> >
</div> </div>
<div class="number-heavy"> <div class="number-heavy">
{{ user.purchased.plan.consecutive.gemCapExtra }} {{ gemCap }}
</div> </div>
</div> </div>
<div class="stats-label"> <div class="stats-label">
{{ $t('gemCapExtra') }} {{ $t('gemCap') }}
</div> </div>
</div> </div>
<div class="stats-spacer"></div> <div class="stats-spacer"></div>
<div> <div class="stat-column">
<div class="d-flex justify-content-center align-items-center"> <div class="d-flex justify-content-center align-items-center">
<div <div
v-once v-once
class="svg-icon svg-hourglass mt-1 mr-2" class="svg-icon svg-hourglass mt-1 mr-1"
v-html="icons.hourglassIcon" v-html="icons.hourglassIcon"
> >
</div> </div>
<div class="number-heavy"> <div class="number-heavy">
{{ user.purchased.plan.consecutive.trinkets }} {{ nextHourGlass }}
</div> </div>
</div> </div>
<div class="stats-label"> <div class="stats-label">
{{ $t('mysticHourglassesTooltip') }} {{ $t('nextHourglass') }}*
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 nextHourglassDescription" v-once>
*{{ $t('nextHourglassDescription') }}
</div>
</div> </div>
<div class="d-flex flex-column justify-content-center align-items-center mt-4 mb-3"> <div class="d-flex flex-column justify-content-center align-items-center mb-3">
<div <div
v-once v-once
class="svg-icon svg-heart mb-1" class="svg-icon svg-heart mb-2"
v-html="icons.heartIcon" v-html="icons.heartIcon"
> >
</div> </div>
<div class="stats-label"> <div class="thanks-for-support">
{{ $t('giftSubscriptionText4') }} {{ $t('giftSubscriptionText4') }}
</div> </div>
</div> </div>
@@ -350,7 +354,7 @@
.cancel-card { .cancel-card {
width: 28rem; width: 28rem;
border: 2px solid $gray-500; border: 2px solid $gray-500;
border-radius: 4px; border-radius: 8px;
} }
.disabled { .disabled {
@@ -405,7 +409,10 @@
} }
.number-heavy { .number-heavy {
font-size: 24px; font-size: 20px;
font-weight: bold;
line-height: 1.4;
color: $gray-50;
} }
.Pet-Jackalope-RoyalPurple { .Pet-Jackalope-RoyalPurple {
@@ -423,7 +430,10 @@
.stats-label { .stats-label {
font-size: 12px; font-size: 12px;
color: $gray-200; color: $gray-100;
margin-top: 6px;
font-weight: bold;
line-height: 1.33;
} }
.stats-spacer { .stats-spacer {
@@ -433,8 +443,9 @@
} }
.subscribe-card { .subscribe-card {
padding-top: 2rem;
width: 28rem; width: 28rem;
border-radius: 4px; border-radius: 8px;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12); box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
background-color: $white; background-color: $white;
} }
@@ -452,7 +463,14 @@
height: 40px; height: 40px;
} }
.svg-calendar, .svg-heart { .svg-calendar {
width: 24px;
height: 24px;
margin-right: 2px;
}
.svg-heart {
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
@@ -479,8 +497,10 @@
} }
.svg-gem { .svg-gem {
width: 32px; width: 24px;
height: 28px; height: 24px;
margin-right: 2px;
} }
.svg-gems { .svg-gems {
@@ -494,8 +514,10 @@
} }
.svg-hourglass { .svg-hourglass {
width: 28px; width: 24px;
height: 28px; height: 24px;
margin-right: 2px;
} }
.svg-gift-box { .svg-gift-box {
@@ -521,11 +543,34 @@
.w-55 { .w-55 {
width: 55%; width: 55%;
} }
.nextHourglassDescription {
font-size: 12px;
font-style: italic;
line-height: 1.33;
color: $gray-100;
margin-left: 100px;
margin-right: 100px;
}
.justify-content-evenly {
justify-content: space-evenly;
}
.thanks-for-support {
font-size: 12px;
line-height: 1.33;
text-align: center;
color: $gray-100;
}
.stat-column {
width: 33%;
}
</style> </style>
<script> <script>
import axios from 'axios'; import axios from 'axios';
import min from 'lodash/min';
import moment from 'moment'; import moment from 'moment';
import { mapState } from '@/libs/store'; import { mapState } from '@/libs/store';
@@ -551,6 +596,7 @@ import logo from '@/assets/svg/habitica-logo-purple.svg';
import paypalLogo from '@/assets/svg/paypal-logo.svg'; import paypalLogo from '@/assets/svg/paypal-logo.svg';
import subscriberGems from '@/assets/svg/subscriber-gems.svg'; import subscriberGems from '@/assets/svg/subscriber-gems.svg';
import subscriberHourglasses from '@/assets/svg/subscriber-hourglasses.svg'; import subscriberHourglasses from '@/assets/svg/subscriber-hourglasses.svg';
import { getPlanContext } from '@/../../common/script/cron';
export default { export default {
components: { components: {
@@ -649,23 +695,9 @@ export default {
months: parseFloat(this.user.purchased.plan.extraMonths).toFixed(2), months: parseFloat(this.user.purchased.plan.extraMonths).toFixed(2),
}; };
}, },
buyGemsGoldCap () { gemCap () {
return { return planGemLimits.convCap
amount: min(this.gemGoldCap), + this.user.purchased.plan.consecutive.gemCapExtra;
};
},
gemGoldCap () {
const baseCap = 25;
const gemCapIncrement = 5;
const capIncrementThreshold = 3;
const { gemCapExtra } = this.user.purchased.plan.consecutive;
const blocks = subscriptionBlocks[this.subscription.key].months / capIncrementThreshold;
const flooredBlocks = Math.floor(blocks);
const userTotalDropCap = baseCap + gemCapExtra + flooredBlocks * gemCapIncrement;
const maxDropCap = 50;
return [userTotalDropCap, maxDropCap];
}, },
numberOfMysticHourglasses () { numberOfMysticHourglasses () {
const numberOfHourglasses = subscriptionBlocks[this.subscription.key].months / 3; const numberOfHourglasses = subscriptionBlocks[this.subscription.key].months / 3;
@@ -719,6 +751,16 @@ export default {
subscriptionEndDate () { subscriptionEndDate () {
return moment(this.user.purchased.plan.dateTerminated).format('MM/DD/YYYY'); return moment(this.user.purchased.plan.dateTerminated).format('MM/DD/YYYY');
}, },
nextHourGlassDate () {
const currentPlanContext = getPlanContext(this.user, new Date());
return currentPlanContext.nextHourglassDate;
},
nextHourGlass () {
const nextHourglassMonth = this.nextHourGlassDate.format('MMM');
return nextHourglassMonth;
},
}, },
mounted () { mounted () {
this.$store.dispatch('common:setTitle', { this.$store.dispatch('common:setTitle', {

View File

@@ -156,9 +156,12 @@
"purchasedPlanExtraMonths": "You have <strong><%= months %> months</strong> of extra subscription credit.", "purchasedPlanExtraMonths": "You have <strong><%= months %> months</strong> of extra subscription credit.",
"consecutiveSubscription": "Consecutive Subscription", "consecutiveSubscription": "Consecutive Subscription",
"consecutiveMonths": "Consecutive Months:", "consecutiveMonths": "Consecutive Months:",
"gemCap": "Gem Cap",
"gemCapExtra": "Gem Cap Bonus", "gemCapExtra": "Gem Cap Bonus",
"mysticHourglasses": "Mystic Hourglasses:", "mysticHourglasses": "Mystic Hourglasses:",
"mysticHourglassesTooltip": "Mystic Hourglasses", "mysticHourglassesTooltip": "Mystic Hourglasses",
"nextHourglass": "Next Hourglass",
"nextHourglassDescription": "Subscribers receive Mystic Hourglasses within\nthe first three days of the month.",
"paypal": "PayPal", "paypal": "PayPal",
"amazonPayments": "Amazon Payments", "amazonPayments": "Amazon Payments",
"amazonPaymentsRecurring": "Ticking the checkbox below is necessary for your subscription to be created. It allows your Amazon account to be used for ongoing payments for <strong>this</strong> subscription. It will not cause your Amazon account to be automatically used for any future purchases.", "amazonPaymentsRecurring": "Ticking the checkbox below is necessary for your subscription to be created. It allows your Amazon account to be used for ongoing payments for <strong>this</strong> subscription. It will not cause your Amazon account to be automatically used for any future purchases.",

View File

@@ -250,3 +250,53 @@ export function shouldDo (day, dailyTask, options = {}) {
} }
return false; return false;
} }
export function getPlanMonths (plan) {
// NB gift subscriptions don't have a planID
// (which doesn't matter because we don't need to reapply perks
// for them and by this point they should have expired anyway)
if (!plan.planId) return 1;
const planIdRegExp = new RegExp('_([0-9]+)mo'); // e.g., matches 'google_6mo' / 'basic_12mo' and captures '6' / '12'
const match = plan.planId.match(planIdRegExp);
if (match !== null && match[0] !== null) {
// 3 for 3-month recurring subscription, etc
return match[1]; // eslint-disable-line prefer-destructuring
}
return 1;
}
/*
* This is a helper method to get all the needed informations of the plan
*
* currently used in cron and the "next hourglass in" feature
*/
export function getPlanContext (user, now) {
const { plan } = user.purchased;
defaults(plan.consecutive, {
count: 0, offset: 0, trinkets: 0, gemCapExtra: 0,
});
const nowMoment = moment(now);
const subscriptionEndDate = moment(plan.dateTerminated).isBefore()
? moment(plan.dateTerminated).startOf('month')
: nowMoment.startOf('month');
const dateUpdatedMoment = moment(plan.dateUpdated).startOf('month');
const elapsedMonths = moment(subscriptionEndDate).diff(dateUpdatedMoment, 'months');
const monthsTillNextHourglass = plan.consecutive.offset || (3 - (plan.consecutive.count % 3));
const possibleNextHourglassDate = moment(plan.dateUpdated)
.add(monthsTillNextHourglass, 'months');
return {
plan,
subscriptionEndDate,
dateUpdatedMoment,
elapsedMonths,
offset: plan.consecutive.offset, // months until the new hourglass is added
nextHourglassDate: possibleNextHourglassDate,
};
}

View File

@@ -25,7 +25,9 @@ import {
import content from './content/index'; import content from './content/index';
import * as count from './count'; import * as count from './count';
// TODO under api.libs.cron? // TODO under api.libs.cron?
import { daysSince, DAY_MAPPING, shouldDo } from './cron'; import {
daysSince, DAY_MAPPING, shouldDo, getPlanContext, getPlanMonths,
} from './cron';
import apiErrors from './errors/apiErrorMessages'; import apiErrors from './errors/apiErrorMessages';
import commonErrors from './errors/commonErrorMessages'; import commonErrors from './errors/commonErrorMessages';
import autoAllocate from './fns/autoAllocate'; import autoAllocate from './fns/autoAllocate';
@@ -93,13 +95,16 @@ import { unEquipByType } from './ops/unequip';
import getOfficialPinnedItems from './libs/getOfficialPinnedItems'; import getOfficialPinnedItems from './libs/getOfficialPinnedItems';
import { sleepAsync } from './libs/sleepAsync'; import { sleepAsync } from './libs/sleepAsync';
const api = {}; const api = {
api.content = content; content,
api.errors = errors; errors,
api.i18n = i18n; i18n,
api.shouldDo = shouldDo; shouldDo,
api.daysSince = daysSince; getPlanContext,
api.DAY_MAPPING = DAY_MAPPING; getPlanMonths,
daysSince,
DAY_MAPPING,
};
api.constants = { api.constants = {
MAX_INCENTIVES, MAX_INCENTIVES,

View File

@@ -11,9 +11,13 @@ import { revealMysteryItems } from './payments/subscriptions';
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true'; const CRON_SEMI_SAFE_MODE = nconf.get('CRON_SEMI_SAFE_MODE') === 'true';
const { MAX_INCENTIVES } = common.constants; const { MAX_INCENTIVES } = common.constants;
const { shouldDo } = common; const {
shouldDo,
i18n,
getPlanContext,
getPlanMonths,
} = common;
const { scoreTask } = common.ops; const { scoreTask } = common.ops;
const { i18n } = common;
const { loginIncentives } = common.content; const { loginIncentives } = common.content;
// const maxPMs = 200; // const maxPMs = 200;
@@ -61,10 +65,8 @@ const CLEAR_BUFFS = {
async function grantEndOfTheMonthPerks (user, now) { async function grantEndOfTheMonthPerks (user, now) {
// multi-month subscriptions are for multiples of 3 months // multi-month subscriptions are for multiples of 3 months
const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3; const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3;
const { plan } = user.purchased;
const subscriptionEndDate = moment(plan.dateTerminated).isBefore() ? moment(plan.dateTerminated).startOf('month') : moment(now).startOf('month'); const { plan, elapsedMonths } = getPlanContext(user, now);
const dateUpdatedMoment = moment(plan.dateUpdated).startOf('month');
const elapsedMonths = moment(subscriptionEndDate).diff(dateUpdatedMoment, 'months');
if (elapsedMonths > 0) { if (elapsedMonths > 0) {
plan.dateUpdated = now; plan.dateUpdated = now;
@@ -72,9 +74,6 @@ async function grantEndOfTheMonthPerks (user, now) {
// Give perks based on consecutive blocks // Give perks based on consecutive blocks
// If they already got perks for those blocks (eg, 6mo subscription, // If they already got perks for those blocks (eg, 6mo subscription,
// subscription gifts, etc) - then dec the offset until it hits 0 // subscription gifts, etc) - then dec the offset until it hits 0
_.defaults(plan.consecutive, {
count: 0, offset: 0, trinkets: 0, gemCapExtra: 0,
});
// Award mystery items // Award mystery items
revealMysteryItems(user, elapsedMonths); revealMysteryItems(user, elapsedMonths);
@@ -104,15 +103,7 @@ async function grantEndOfTheMonthPerks (user, now) {
if (plan.consecutive.offset < 0) { if (plan.consecutive.offset < 0) {
if (plan.planId) { if (plan.planId) {
// NB gift subscriptions don't have a planID planMonthsLength = getPlanMonths(plan);
// (which doesn't matter because we don't need to reapply perks
// for them and by this point they should have expired anyway)
const planIdRegExp = new RegExp('_([0-9]+)mo'); // e.g., matches 'google_6mo' / 'basic_12mo' and captures '6' / '12'
const match = plan.planId.match(planIdRegExp);
if (match !== null && match[0] !== null) {
// 3 for 3-month recurring subscription, etc
planMonthsLength = match[1]; // eslint-disable-line prefer-destructuring
}
} }
// every 3 months you get one set of perks - this variable records how many sets you need // every 3 months you get one set of perks - this variable records how many sets you need