From 68a0a8f23c17940dd69eb15850e2e1e7239d317e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 5 Sep 2025 19:39:50 +0200 Subject: [PATCH 1/5] Add follow up emails --- CHANGELOG.md | 12 ++ app/assets/builds/tailwind.css | 2 +- app/helpers/application_helper.rb | 52 ------ app/helpers/stats_helper.rb | 55 +++++++ app/jobs/bulk_stats_calculating_job.rb | 2 +- app/jobs/cache/preheating_job.rb | 18 +++ app/jobs/users/mailer_sending_job.rb | 34 +++- app/mailers/users_mailer.rb | 36 +++++ app/models/user.rb | 76 ++++++++- app/queries/stats_query.rb | 22 ++- app/services/cache/clean.rb | 15 ++ app/views/stats/index.html.erb | 11 +- config/initializers/cache_jobs.rb | 2 - ..._user_country_composite_index_to_points.rb | 12 ++ db/schema.rb | 3 +- spec/jobs/bulk_stats_calculating_job_spec.rb | 151 ++++++++++++++++-- spec/jobs/cache/preheating_job_spec.rb | 69 ++++++++ spec/queries/stats_query_spec.rb | 44 +++++ spec/services/cache/clean_spec.rb | 88 ++++++++++ 19 files changed, 614 insertions(+), 90 deletions(-) create mode 100644 app/helpers/stats_helper.rb create mode 100644 db/migrate/20250905120121_add_user_country_composite_index_to_points.rb create mode 100644 spec/jobs/cache/preheating_job_spec.rb create mode 100644 spec/services/cache/clean_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 37fdb788..7e47904f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [UNRELEASED] + +## Changed + +- Stats page now loads significantly faster due to caching +- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour. + +## Fixed + +- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466 +- Stats are now being calculated for trial users as well as active ones. + # [0.31.0] - 2025-09-04 The Search release diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 2b37c492..1b6b0a1a 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -3,4 +3,4 @@ );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact -.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file +.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2fb02162..11337777 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -29,58 +29,6 @@ module ApplicationHelper %w[info success warning error accent secondary primary] end - def countries_and_cities_stat_for_year(year, stats) - data = { countries: [], cities: [] } - - stats.select { _1.year == year }.each do - data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact - data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq - end - - data[:cities].flatten!.uniq! - data[:countries].flatten!.uniq! - - grouped_by_country = {} - stats.select { _1.year == year }.each do |stat| - stat.toponyms.flatten.each do |toponym| - country = toponym['country'] - next unless country.present? - - grouped_by_country[country] ||= [] - - next unless toponym['cities'].present? - - toponym['cities'].each do |city_data| - city = city_data['city'] - grouped_by_country[country] << city if city.present? - end - end - end - - grouped_by_country.transform_values!(&:uniq) - - { - countries_count: data[:countries].count, - cities_count: data[:cities].count, - grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h, - year: year, - modal_id: "countries_cities_modal_#{year}" - } - end - - def countries_and_cities_stat_for_month(stat) - countries = stat.toponyms.count { _1['country'] } - cities = stat.toponyms.sum { _1['cities'].count } - - "#{countries} countries, #{cities} cities" - end - - def year_distance_stat(year, user) - # Distance is now stored in meters, convert to user's preferred unit for display - total_distance_meters = Stat.year_distance(year, user).sum { _1[1] } - Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit) - end - def past?(year, month) DateTime.new(year, month).past? end diff --git a/app/helpers/stats_helper.rb b/app/helpers/stats_helper.rb new file mode 100644 index 00000000..509afc8a --- /dev/null +++ b/app/helpers/stats_helper.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module StatsHelper + def year_distance_stat(year_data, user) + total_distance_meters = year_data.sum { _1[1] } + + Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit) + end + + def countries_and_cities_stat_for_year(year, stats) + data = { countries: [], cities: [] } + + stats.select { _1.year == year }.each do + data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact + data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq + end + + data[:cities].flatten!.uniq! + data[:countries].flatten!.uniq! + + grouped_by_country = {} + stats.select { _1.year == year }.each do |stat| + stat.toponyms.flatten.each do |toponym| + country = toponym['country'] + next if country.blank? + + grouped_by_country[country] ||= [] + + next if toponym['cities'].blank? + + toponym['cities'].each do |city_data| + city = city_data['city'] + grouped_by_country[country] << city if city.present? + end + end + end + + grouped_by_country.transform_values!(&:uniq) + + { + countries_count: data[:countries].count, + cities_count: data[:cities].count, + grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h, + year: year, + modal_id: "countries_cities_modal_#{year}" + } + end + + def countries_and_cities_stat_for_month(stat) + countries = stat.toponyms.count { _1['country'] } + cities = stat.toponyms.sum { _1['cities'].count } + + "#{countries} countries, #{cities} cities" + end +end diff --git a/app/jobs/bulk_stats_calculating_job.rb b/app/jobs/bulk_stats_calculating_job.rb index 4311a361..8decdf11 100644 --- a/app/jobs/bulk_stats_calculating_job.rb +++ b/app/jobs/bulk_stats_calculating_job.rb @@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob queue_as :stats def perform - user_ids = User.active.pluck(:id) + user_ids = User.active.pluck(:id) + User.trial.pluck(:id) user_ids.each do |user_id| Stats::BulkCalculator.new(user_id).call diff --git a/app/jobs/cache/preheating_job.rb b/app/jobs/cache/preheating_job.rb index 741f9014..5a7ad44e 100644 --- a/app/jobs/cache/preheating_job.rb +++ b/app/jobs/cache/preheating_job.rb @@ -10,6 +10,24 @@ class Cache::PreheatingJob < ApplicationJob user.years_tracked, expires_in: 1.day ) + + Rails.cache.write( + "dawarich/user_#{user.id}_points_geocoded_stats", + StatsQuery.new(user).send(:cached_points_geocoded_stats), + expires_in: 1.day + ) + + Rails.cache.write( + "dawarich/user_#{user.id}_countries_visited", + user.send(:countries_visited_uncached), + expires_in: 1.day + ) + + Rails.cache.write( + "dawarich/user_#{user.id}_cities_visited", + user.send(:cities_visited_uncached), + expires_in: 1.day + ) end end end diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb index bbce993f..be7e203f 100644 --- a/app/jobs/users/mailer_sending_job.rb +++ b/app/jobs/users/mailer_sending_job.rb @@ -6,8 +6,8 @@ class Users::MailerSendingJob < ApplicationJob def perform(user_id, email_type, **options) user = User.find(user_id) - if trial_related_email?(email_type) && user.active? - Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed" + if should_skip_email?(user, email_type) + Rails.logger.info "Skipping #{email_type} email for user #{user_id} - #{skip_reason(user, email_type)}" return end @@ -18,7 +18,33 @@ class Users::MailerSendingJob < ApplicationJob private - def trial_related_email?(email_type) - %w[trial_expires_soon trial_expired].include?(email_type.to_s) + def should_skip_email?(user, email_type) + case email_type.to_s + when 'trial_expires_soon', 'trial_expired' + user.active? + when 'post_trial_reminder_early', 'post_trial_reminder_late' + user.active? || !user.trial? + when 'subscription_expires_soon_early', 'subscription_expires_soon_late' + !user.active? || !user.active_until&.future? + when 'subscription_expired_early', 'subscription_expired_late' + user.active? || user.active_until&.future? || user.trial? + else + false + end + end + + def skip_reason(user, email_type) + case email_type.to_s + when 'trial_expires_soon', 'trial_expired' + 'user is already subscribed' + when 'post_trial_reminder_early', 'post_trial_reminder_late' + user.active? ? 'user is subscribed' : 'user is not in trial state' + when 'subscription_expires_soon_early', 'subscription_expires_soon_late' + 'user is not active or subscription already expired' + when 'subscription_expired_early', 'subscription_expired_late' + 'user is active, subscription not expired, or user is in trial' + else + 'unknown reason' + end end end diff --git a/app/mailers/users_mailer.rb b/app/mailers/users_mailer.rb index c7293a75..a06d3dc9 100644 --- a/app/mailers/users_mailer.rb +++ b/app/mailers/users_mailer.rb @@ -24,4 +24,40 @@ class UsersMailer < ApplicationMailer mail(to: @user.email, subject: 'πŸ’” Your Dawarich trial expired') end + + def post_trial_reminder_early + @user = params[:user] + + mail(to: @user.email, subject: 'πŸš€ Still interested in Dawarich? Subscribe now!') + end + + def post_trial_reminder_late + @user = params[:user] + + mail(to: @user.email, subject: 'πŸ“ Your location data is waiting - Subscribe to Dawarich') + end + + def subscription_expires_soon_early + @user = params[:user] + + mail(to: @user.email, subject: '⚠️ Your Dawarich subscription expires in 14 days') + end + + def subscription_expires_soon_late + @user = params[:user] + + mail(to: @user.email, subject: '🚨 Your Dawarich subscription expires in 2 days') + end + + def subscription_expired_early + @user = params[:user] + + mail(to: @user.email, subject: 'πŸ’” Your Dawarich subscription expired - Reactivate now') + end + + def subscription_expired_late + @user = params[:user] + + mail(to: @user.email, subject: 'πŸ“ Missing your location insights? Renew Dawarich subscription') + end end diff --git a/app/models/user.rb b/app/models/user.rb index 96d3e3a7..07404663 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? } + after_commit :schedule_subscription_emails_on_activation, if: :should_schedule_subscription_emails? before_save :sanitize_input @@ -35,15 +36,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength end def countries_visited - points - .where.not(country_name: [nil, '']) - .distinct - .pluck(:country_name) - .compact + Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do + points + .without_raw_data + .where.not(country_name: [nil, '']) + .distinct + .pluck(:country_name) + .compact + end end def cities_visited - points.where.not(city: [nil, '']).distinct.pluck(:city).compact + Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do + points.where.not(city: [nil, '']).distinct.pluck(:city).compact + end end def total_distance @@ -151,5 +157,63 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features') Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon') Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired') + schedule_post_trial_emails + end + + def schedule_post_trial_emails + Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early') + Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late') + end + + def schedule_subscription_expiry_emails + return unless active? && active_until&.future? + + days_until_expiry = (active_until.to_date - Time.current.to_date).to_i + + if days_until_expiry >= 14 + Users::MailerSendingJob.set(wait: (days_until_expiry - 14).days).perform_later(id, + 'subscription_expires_soon_early') + end + + if days_until_expiry >= 2 + Users::MailerSendingJob.set(wait: (days_until_expiry - 2).days).perform_later(id, + 'subscription_expires_soon_late') + end + + schedule_subscription_expired_emails + end + + def schedule_subscription_expired_emails + return unless active? && active_until&.future? + + days_until_expiry = (active_until.to_date - Time.current.to_date).to_i + + Users::MailerSendingJob.set(wait: (days_until_expiry + 7).days).perform_later(id, 'subscription_expired_early') + Users::MailerSendingJob.set(wait: (days_until_expiry + 14).days).perform_later(id, 'subscription_expired_late') + end + + def should_schedule_subscription_emails? + return false unless persisted? + + # Schedule if status changed to active or active_until was updated for an active user + (saved_change_to_status? && status == 'active') || + (saved_change_to_active_until? && active? && active_until&.future?) + end + + def schedule_subscription_emails_on_activation + schedule_subscription_expiry_emails + end + + def countries_visited_uncached + points + .without_raw_data + .where.not(country_name: [nil, '']) + .distinct + .pluck(:country_name) + .compact + end + + def cities_visited_uncached + points.where.not(city: [nil, '']).distinct.pluck(:city).compact end end diff --git a/app/queries/stats_query.rb b/app/queries/stats_query.rb index 0192a8c8..e81fa1f6 100644 --- a/app/queries/stats_query.rb +++ b/app/queries/stats_query.rb @@ -6,10 +6,25 @@ class StatsQuery end def points_stats + cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do + cached_points_geocoded_stats + end + + { + total: user.points_count, + geocoded: cached_stats[:geocoded], + without_data: cached_stats[:without_data] + } + end + + private + + attr_reader :user + + def cached_points_geocoded_stats sql = ActiveRecord::Base.sanitize_sql_array([ <<~SQL.squish, SELECT - COUNT(id) as total, COUNT(reverse_geocoded_at) as geocoded, COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data FROM points @@ -21,13 +36,8 @@ class StatsQuery result = Point.connection.select_one(sql) { - total: result['total'].to_i, geocoded: result['geocoded'].to_i, without_data: result['without_data'].to_i } end - - private - - attr_reader :user end diff --git a/app/services/cache/clean.rb b/app/services/cache/clean.rb index 15647b99..e555e6a4 100644 --- a/app/services/cache/clean.rb +++ b/app/services/cache/clean.rb @@ -7,6 +7,8 @@ class Cache::Clean delete_control_flag delete_version_cache delete_years_tracked_cache + delete_points_geocoded_stats_cache + delete_countries_cities_cache Rails.logger.info('Cache cleaned') end @@ -25,5 +27,18 @@ class Cache::Clean Rails.cache.delete("dawarich/user_#{user.id}_years_tracked") end end + + def delete_points_geocoded_stats_cache + User.find_each do |user| + Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats") + end + end + + def delete_countries_cities_cache + User.find_each do |user| + Rails.cache.delete("dawarich/user_#{user.id}_countries_visited") + Rails.cache.delete("dawarich/user_#{user.id}_cities_visited") + end + end end end diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index bd06de8e..fec5a373 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -1,7 +1,7 @@ <% content_for :title, 'Statistics' %>
-
+
<%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %> @@ -21,6 +21,11 @@ <% end %>
+
+ All stats data above except for total distance and number of geopoints tracked is being updated daily +
+ + <% if current_user.active? %> <%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %> <% end %> @@ -40,9 +45,7 @@

- <% cache [current_user, 'year_distance_stat', year], skip_digest: true do %> - <%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %> - <% end %> + <%= number_with_delimiter year_distance_stat(@year_distances[year], current_user).round %> <%= current_user.safe_settings.distance_unit %>

<% if DawarichSettings.reverse_geocoding_enabled? %>
diff --git a/config/initializers/cache_jobs.rb b/config/initializers/cache_jobs.rb index 0cc21349..9e89cbf9 100644 --- a/config/initializers/cache_jobs.rb +++ b/config/initializers/cache_jobs.rb @@ -3,10 +3,8 @@ Rails.application.config.after_initialize do # Only run in server mode and ensure one-time execution with atomic write if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true) - # Clear the cache Cache::CleaningJob.perform_later - # Preheat the cache Cache::PreheatingJob.perform_later end end diff --git a/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb b/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb new file mode 100644 index 00000000..afe2643a --- /dev/null +++ b/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddUserCountryCompositeIndexToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :points, %i[user_id country_name], + algorithm: :concurrently, + name: 'idx_points_user_country_name', + if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6cb87072..93080ae7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do +ActiveRecord::Schema[8.0].define(version: 2025_09_05_120121) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -205,6 +205,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do t.index ["timestamp"], name: "index_points_on_timestamp" t.index ["track_id"], name: "index_points_on_track_id" t.index ["trigger"], name: "index_points_on_trigger" + t.index ["user_id", "country_name"], name: "idx_points_user_country_name" t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation" t.index ["user_id"], name: "index_points_on_user_id" t.index ["visit_id"], name: "index_points_on_visit_id" diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb index 632fa47e..eb59c46a 100644 --- a/spec/jobs/bulk_stats_calculating_job_spec.rb +++ b/spec/jobs/bulk_stats_calculating_job_spec.rb @@ -4,28 +4,153 @@ require 'rails_helper' RSpec.describe BulkStatsCalculatingJob, type: :job do describe '#perform' do - let(:user1) { create(:user) } - let(:user2) { create(:user) } - let(:timestamp) { DateTime.new(2024, 1, 1).to_i } - let!(:points1) do - (1..10).map do |i| - create(:point, user_id: user1.id, timestamp: timestamp + i.minutes) + context 'with active users' do + let!(:active_user1) { create(:user, status: :active) } + let!(:active_user2) { create(:user, status: :active) } + + let!(:points1) do + (1..10).map do |i| + create(:point, user_id: active_user1.id, timestamp: timestamp + i.minutes) + end + end + + let!(:points2) do + (1..10).map do |i| + create(:point, user_id: active_user2.id, timestamp: timestamp + i.minutes) + end + end + + before do + # Remove any leftover users from other tests, keeping only our test users + User.where.not(id: [active_user1.id, active_user2.id]).destroy_all + allow(Stats::BulkCalculator).to receive(:new).and_call_original + allow_any_instance_of(Stats::BulkCalculator).to receive(:call) + end + + it 'processes all active users' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).with(active_user1.id) + expect(Stats::BulkCalculator).to have_received(:new).with(active_user2.id) + end + + it 'calls Stats::BulkCalculator for each active user' do + calculator1 = instance_double(Stats::BulkCalculator) + calculator2 = instance_double(Stats::BulkCalculator) + + allow(Stats::BulkCalculator).to receive(:new).with(active_user1.id).and_return(calculator1) + allow(Stats::BulkCalculator).to receive(:new).with(active_user2.id).and_return(calculator2) + allow(calculator1).to receive(:call) + allow(calculator2).to receive(:call) + + BulkStatsCalculatingJob.perform_now + + expect(calculator1).to have_received(:call) + expect(calculator2).to have_received(:call) end end - let!(:points2) do - (1..10).map do |i| - create(:point, user_id: user2.id, timestamp: timestamp + i.minutes) + context 'with trial users' do + let!(:trial_user1) { create(:user, status: :trial) } + let!(:trial_user2) { create(:user, status: :trial) } + + let!(:points1) do + (1..5).map do |i| + create(:point, user_id: trial_user1.id, timestamp: timestamp + i.minutes) + end + end + + let!(:points2) do + (1..5).map do |i| + create(:point, user_id: trial_user2.id, timestamp: timestamp + i.minutes) + end + end + + before do + # Remove any leftover users from other tests, keeping only our test users + User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all + allow(Stats::BulkCalculator).to receive(:new).and_call_original + allow_any_instance_of(Stats::BulkCalculator).to receive(:call) + end + + it 'processes all trial users' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).with(trial_user1.id) + expect(Stats::BulkCalculator).to have_received(:new).with(trial_user2.id) + end + + it 'calls Stats::BulkCalculator for each trial user' do + calculator1 = instance_double(Stats::BulkCalculator) + calculator2 = instance_double(Stats::BulkCalculator) + + allow(Stats::BulkCalculator).to receive(:new).with(trial_user1.id).and_return(calculator1) + allow(Stats::BulkCalculator).to receive(:new).with(trial_user2.id).and_return(calculator2) + allow(calculator1).to receive(:call) + allow(calculator2).to receive(:call) + + BulkStatsCalculatingJob.perform_now + + expect(calculator1).to have_received(:call) + expect(calculator2).to have_received(:call) end end - it 'enqueues Stats::CalculatingJob for each user' do - expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1) - expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1) + context 'with inactive users only' do + before do + allow(User).to receive(:active).and_return(User.none) + allow(User).to receive(:trial).and_return(User.none) + allow(Stats::BulkCalculator).to receive(:new) + end - BulkStatsCalculatingJob.perform_now + it 'does not process any users when no active or trial users exist' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).not_to have_received(:new) + end + + it 'queries for active and trial users but finds none' do + BulkStatsCalculatingJob.perform_now + + expect(User).to have_received(:active) + expect(User).to have_received(:trial) + end + end + + context 'with mixed user types' do + let(:active_user) { create(:user, status: :active) } + let(:trial_user) { create(:user, status: :trial) } + let(:inactive_user) { create(:user, status: :inactive) } + + before do + active_users_relation = double('ActiveRecord::Relation') + trial_users_relation = double('ActiveRecord::Relation') + + allow(active_users_relation).to receive(:pluck).with(:id).and_return([active_user.id]) + allow(trial_users_relation).to receive(:pluck).with(:id).and_return([trial_user.id]) + + allow(User).to receive(:active).and_return(active_users_relation) + allow(User).to receive(:trial).and_return(trial_users_relation) + + allow(Stats::BulkCalculator).to receive(:new).and_call_original + allow_any_instance_of(Stats::BulkCalculator).to receive(:call) + end + + it 'processes only active and trial users, skipping inactive users' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).with(active_user.id) + expect(Stats::BulkCalculator).to have_received(:new).with(trial_user.id) + expect(Stats::BulkCalculator).not_to have_received(:new).with(inactive_user.id) + end + + it 'processes exactly 2 users (active and trial)' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).exactly(2).times + end end end end diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb new file mode 100644 index 00000000..f32f31a6 --- /dev/null +++ b/spec/jobs/cache/preheating_job_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Cache::PreheatingJob do + before do + Rails.cache.clear + end + + describe '#perform' do + let!(:user1) { create(:user) } + let!(:user2) { create(:user) } + let!(:import1) { create(:import, user: user1) } + let!(:import2) { create(:import, user: user2) } + + before do + create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current) + create_list(:point, 2, user: user2, import: import2, reverse_geocoded_at: Time.current) + end + + it 'preheats years_tracked cache for all users' do + expect(Rails.cache).to receive(:write).with( + "dawarich/user_#{user1.id}_years_tracked", + anything, + expires_in: 1.day + ) + expect(Rails.cache).to receive(:write).with( + "dawarich/user_#{user2.id}_years_tracked", + anything, + expires_in: 1.day + ) + + described_class.new.perform + end + + it 'preheats points_geocoded_stats cache for all users' do + expect(Rails.cache).to receive(:write).with( + "dawarich/user_#{user1.id}_points_geocoded_stats", + { geocoded: 3, without_data: 0 }, + expires_in: 1.day + ) + expect(Rails.cache).to receive(:write).with( + "dawarich/user_#{user2.id}_points_geocoded_stats", + { geocoded: 2, without_data: 0 }, + expires_in: 1.day + ) + + described_class.new.perform + end + + it 'actually writes to cache' do + described_class.new.perform + + expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true + end + + it 'handles users with no points gracefully' do + user_no_points = create(:user) + + expect { described_class.new.perform }.not_to raise_error + + cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats") + expect(cached_stats).to eq({ geocoded: 0, without_data: 0 }) + end + end +end \ No newline at end of file diff --git a/spec/queries/stats_query_spec.rb b/spec/queries/stats_query_spec.rb index d4d8517f..6347e4e6 100644 --- a/spec/queries/stats_query_spec.rb +++ b/spec/queries/stats_query_spec.rb @@ -3,6 +3,9 @@ require 'rails_helper' RSpec.describe StatsQuery do + before do + Rails.cache.clear + end describe '#points_stats' do subject(:points_stats) { described_class.new(user).points_stats } @@ -126,5 +129,46 @@ RSpec.describe StatsQuery do }) end end + + describe 'caching behavior' do + let!(:points) do + create_list(:point, 2, + user: user, + import: import, + reverse_geocoded_at: Time.current, + geodata: { 'address' => 'Test Address' }) + end + + it 'caches the geocoded stats' do + expect(Rails.cache).to receive(:fetch).with( + "dawarich/user_#{user.id}_points_geocoded_stats", + expires_in: 1.day + ).and_call_original + + points_stats + end + + it 'returns cached results on subsequent calls' do + # First call - should hit database and cache + expect(Point.connection).to receive(:select_one).once.and_call_original + first_result = points_stats + + # Second call - should use cache, not hit database + expect(Point.connection).not_to receive(:select_one) + second_result = points_stats + + expect(first_result).to eq(second_result) + end + + it 'uses counter cache for total count' do + # Ensure counter cache is set correctly + user.reload + expect(user.points_count).to eq(2) + + # The total should come from counter cache, not from SQL + result = points_stats + expect(result[:total]).to eq(user.points_count) + end + end end end \ No newline at end of file diff --git a/spec/services/cache/clean_spec.rb b/spec/services/cache/clean_spec.rb new file mode 100644 index 00000000..84c0b9f7 --- /dev/null +++ b/spec/services/cache/clean_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Cache::Clean do + before do + Rails.cache.clear + end + + describe '.call' do + let!(:user1) { create(:user) } + let!(:user2) { create(:user) } + + before do + # Set up cache entries that should be cleaned + Rails.cache.write('cache_jobs_scheduled', true) + Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0') + Rails.cache.write("dawarich/user_#{user1.id}_years_tracked", { 2023 => ['Jan', 'Feb'] }) + Rails.cache.write("dawarich/user_#{user2.id}_years_tracked", { 2023 => ['Mar', 'Apr'] }) + Rails.cache.write("dawarich/user_#{user1.id}_points_geocoded_stats", { geocoded: 5, without_data: 2 }) + Rails.cache.write("dawarich/user_#{user2.id}_points_geocoded_stats", { geocoded: 3, without_data: 1 }) + end + + it 'deletes control flag cache' do + expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true + + described_class.call + + expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false + end + + it 'deletes version cache' do + expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true + + described_class.call + + expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false + end + + it 'deletes years tracked cache for all users' do + expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true + + described_class.call + + expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be false + expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be false + end + + it 'deletes points geocoded stats cache for all users' do + expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true + + described_class.call + + expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be false + expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be false + end + + it 'logs cache cleaning process' do + expect(Rails.logger).to receive(:info).with('Cleaning cache...') + expect(Rails.logger).to receive(:info).with('Cache cleaned') + + described_class.call + end + + it 'handles users being added during execution gracefully' do + # Create a user that will be found during the cleaning process + user3 = nil + + allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block| + # Create a new user while iterating - this should not cause errors + user3 = create(:user) + Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] }) + Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 }) + + # Continue with the original block + [user1, user2].each(&block) + end + + expect { described_class.call }.not_to raise_error + + # The new user's cache should still exist since it wasn't processed + expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true + end + end +end \ No newline at end of file From 774860220ea07ce4bd77d7d3c06884cf9abe8144 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 13 Sep 2025 15:37:09 +0200 Subject: [PATCH 2/5] Add missing email templates for post-trial reminders --- CHANGELOG.md | 2 +- app/helpers/application_helper.rb | 52 ------------ app/jobs/users/mailer_sending_job.rb | 8 -- app/mailers/users_mailer.rb | 30 ++----- app/models/user.rb | 40 ---------- app/views/stats/index.html.erb | 1 - .../users_mailer/explore_features.html.erb | 9 ++- .../users_mailer/explore_features.text.erb | 7 +- .../post_trial_reminder_early.html.erb | 49 ++++++++++++ .../post_trial_reminder_early.text.erb | 24 ++++++ .../post_trial_reminder_late.html.erb | 51 ++++++++++++ .../post_trial_reminder_late.text.erb | 26 ++++++ app/views/users_mailer/trial_expired.html.erb | 6 +- app/views/users_mailer/trial_expired.text.erb | 6 +- .../users_mailer/trial_expires_soon.html.erb | 6 +- .../users_mailer/trial_expires_soon.text.erb | 6 +- app/views/users_mailer/welcome.html.erb | 6 +- app/views/users_mailer/welcome.text.erb | 6 +- spec/jobs/cache/preheating_job_spec.rb | 10 +-- spec/mailers/users_mailer_spec.rb | 38 ++++----- spec/queries/stats_query_spec.rb | 79 +++++++++++-------- spec/services/cache/clean_spec.rb | 60 +++++++------- 22 files changed, 286 insertions(+), 236 deletions(-) create mode 100644 app/views/users_mailer/post_trial_reminder_early.html.erb create mode 100644 app/views/users_mailer/post_trial_reminder_early.text.erb create mode 100644 app/views/users_mailer/post_trial_reminder_late.html.erb create mode 100644 app/views/users_mailer/post_trial_reminder_late.text.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 718c547d..1f0cc432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Changed -- Stats page now loads significantly faster due to caching +- Stats page now loads significantly faster due to caching. - Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour. - Minor versions are now being built only for amd64 architecture to speed up the build process. - If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error. diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 113e6ec7..5b453fbc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -21,58 +21,6 @@ module ApplicationHelper %w[info success warning error accent secondary primary] end - def countries_and_cities_stat_for_year(year, stats) - data = { countries: [], cities: [] } - - stats.select { _1.year == year }.each do - data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact - data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq - end - - data[:cities].flatten!.uniq! - data[:countries].flatten!.uniq! - - grouped_by_country = {} - stats.select { _1.year == year }.each do |stat| - stat.toponyms.flatten.each do |toponym| - country = toponym['country'] - next if country.blank? - - grouped_by_country[country] ||= [] - - next if toponym['cities'].blank? - - toponym['cities'].each do |city_data| - city = city_data['city'] - grouped_by_country[country] << city if city.present? - end - end - end - - grouped_by_country.transform_values!(&:uniq) - - { - countries_count: data[:countries].count, - cities_count: data[:cities].count, - grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h, - year: year, - modal_id: "countries_cities_modal_#{year}" - } - end - - def countries_and_cities_stat_for_month(stat) - countries = stat.toponyms.count { _1['country'] } - cities = stat.toponyms.sum { _1['cities'].count } - - "#{countries} countries, #{cities} cities" - end - - def year_distance_stat(year, user) - # Distance is now stored in meters, convert to user's preferred unit for display - total_distance_meters = Stat.year_distance(year, user).sum { _1[1] } - Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit) - end - def new_version_available? CheckAppVersion.new.call end diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb index fef4fcea..ae8b80dc 100644 --- a/app/jobs/users/mailer_sending_job.rb +++ b/app/jobs/users/mailer_sending_job.rb @@ -26,10 +26,6 @@ class Users::MailerSendingJob < ApplicationJob user.active? when 'post_trial_reminder_early', 'post_trial_reminder_late' user.active? || !user.trial? - when 'subscription_expires_soon_early', 'subscription_expires_soon_late' - !user.active? || !user.active_until&.future? - when 'subscription_expired_early', 'subscription_expired_late' - user.active? || user.active_until&.future? || user.trial? else false end @@ -41,10 +37,6 @@ class Users::MailerSendingJob < ApplicationJob 'user is already subscribed' when 'post_trial_reminder_early', 'post_trial_reminder_late' user.active? ? 'user is subscribed' : 'user is not in trial state' - when 'subscription_expires_soon_early', 'subscription_expires_soon_late' - 'user is not active or subscription already expired' - when 'subscription_expired_early', 'subscription_expired_late' - 'user is active, subscription not expired, or user is in trial' else 'unknown reason' end diff --git a/app/mailers/users_mailer.rb b/app/mailers/users_mailer.rb index a06d3dc9..95afd3ea 100644 --- a/app/mailers/users_mailer.rb +++ b/app/mailers/users_mailer.rb @@ -2,62 +2,44 @@ class UsersMailer < ApplicationMailer def welcome + # Sent after user signs up @user = params[:user] mail(to: @user.email, subject: 'Welcome to Dawarich!') end def explore_features + # Sent 2 days after user signs up @user = params[:user] mail(to: @user.email, subject: 'Explore Dawarich features!') end def trial_expires_soon + # Sent 2 days before trial expires @user = params[:user] mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days') end def trial_expired + # Sent when trial expires @user = params[:user] mail(to: @user.email, subject: 'πŸ’” Your Dawarich trial expired') end def post_trial_reminder_early + # Sent 2 days after trial expires @user = params[:user] mail(to: @user.email, subject: 'πŸš€ Still interested in Dawarich? Subscribe now!') end def post_trial_reminder_late + # Sent 7 days after trial expires @user = params[:user] mail(to: @user.email, subject: 'πŸ“ Your location data is waiting - Subscribe to Dawarich') end - - def subscription_expires_soon_early - @user = params[:user] - - mail(to: @user.email, subject: '⚠️ Your Dawarich subscription expires in 14 days') - end - - def subscription_expires_soon_late - @user = params[:user] - - mail(to: @user.email, subject: '🚨 Your Dawarich subscription expires in 2 days') - end - - def subscription_expired_early - @user = params[:user] - - mail(to: @user.email, subject: 'πŸ’” Your Dawarich subscription expired - Reactivate now') - end - - def subscription_expired_late - @user = params[:user] - - mail(to: @user.email, subject: 'πŸ“ Missing your location insights? Renew Dawarich subscription') - end end diff --git a/app/models/user.rb b/app/models/user.rb index 4ff89d3b..19da0fd3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,7 +18,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? } - after_commit :schedule_subscription_emails_on_activation, if: :should_schedule_subscription_emails? before_save :sanitize_input @@ -170,45 +169,6 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late') end - def schedule_subscription_expiry_emails - return unless active? && active_until&.future? - - days_until_expiry = (active_until.to_date - Time.current.to_date).to_i - - if days_until_expiry >= 14 - Users::MailerSendingJob.set(wait: (days_until_expiry - 14).days).perform_later(id, - 'subscription_expires_soon_early') - end - - if days_until_expiry >= 2 - Users::MailerSendingJob.set(wait: (days_until_expiry - 2).days).perform_later(id, - 'subscription_expires_soon_late') - end - - schedule_subscription_expired_emails - end - - def schedule_subscription_expired_emails - return unless active? && active_until&.future? - - days_until_expiry = (active_until.to_date - Time.current.to_date).to_i - - Users::MailerSendingJob.set(wait: (days_until_expiry + 7).days).perform_later(id, 'subscription_expired_early') - Users::MailerSendingJob.set(wait: (days_until_expiry + 14).days).perform_later(id, 'subscription_expired_late') - end - - def should_schedule_subscription_emails? - return false unless persisted? - - # Schedule if status changed to active or active_until was updated for an active user - (saved_change_to_status? && status == 'active') || - (saved_change_to_active_until? && active? && active_until&.future?) - end - - def schedule_subscription_emails_on_activation - schedule_subscription_expiry_emails - end - def countries_visited_uncached points .without_raw_data diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 706e30b3..926b5b5e 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -25,7 +25,6 @@ All stats data above except for total distance and number of geopoints tracked is being updated daily
- <% if current_user.active? %> <%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %> <% end %> diff --git a/app/views/users_mailer/explore_features.html.erb b/app/views/users_mailer/explore_features.html.erb index 9d8c64c0..9e70e509 100644 --- a/app/views/users_mailer/explore_features.html.erb +++ b/app/views/users_mailer/explore_features.html.erb @@ -17,12 +17,17 @@

Explore Dawarich Features

-

Hi <%= @user.email %>,

+

Hi <%= @user.email %>, this is Evgenii from Dawarich.

-

You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.

+

You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.

Here are some powerful features you might want to explore:

+
+

✈️ Reliving your travels

+

Revisit your past journeys with detailed maps and insights.

+
+

πŸ“Š Statistics & Analytics

View detailed insights about distances traveled and time spent in different locations.

diff --git a/app/views/users_mailer/explore_features.text.erb b/app/views/users_mailer/explore_features.text.erb index 0ffa8e99..e6b92042 100644 --- a/app/views/users_mailer/explore_features.text.erb +++ b/app/views/users_mailer/explore_features.text.erb @@ -1,11 +1,14 @@ Explore Dawarich Features -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. -You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data. +You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data. Here are some powerful features you might want to explore: +✈️ Reliving your travels +Revisit your past journeys with detailed maps and insights. + πŸ“Š Statistics & Analytics View detailed insights about distances traveled and time spent in different locations. diff --git a/app/views/users_mailer/post_trial_reminder_early.html.erb b/app/views/users_mailer/post_trial_reminder_early.html.erb new file mode 100644 index 00000000..c2459943 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_early.html.erb @@ -0,0 +1,49 @@ + + + + + + + +
+
+

πŸš€ Still Interested in Dawarich?

+
+
+

Hi <%= @user.email %>, this is Evgenii from Dawarich.

+ +
+

Your Dawarich trial ended 2 days ago.

+
+ +

I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!

+ +

Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.

+ +

🌟 What you're missing:

+
    +
  • Real-time location tracking and analysis
  • +
  • Beautiful, interactive maps with your travel history
  • +
  • Detailed statistics and insights about your journeys
  • +
  • Data export capabilities for your peace of mind
  • +
+ + Subscribe Now + +

Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!

+ +

Questions? Just reply to this email – I'm here to help.

+ +

Best regards,
+ Evgenii from Dawarich

+
+
+ + diff --git a/app/views/users_mailer/post_trial_reminder_early.text.erb b/app/views/users_mailer/post_trial_reminder_early.text.erb new file mode 100644 index 00000000..be2edcc8 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_early.text.erb @@ -0,0 +1,24 @@ +πŸš€ Still Interested in Dawarich? + +Hi <%= @user.email %>, + +Your Dawarich trial ended 2 days ago. + +I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer! + +Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off. + +🌟 What you're missing: +- Real-time location tracking and analysis +- Beautiful, interactive maps with your travel history +- Detailed statistics and insights about your journeys +- Data export capabilities for your peace of mind + +Subscribe now: https://my.dawarich.app + +Ready to unlock your location story? Subscribe today and continue your journey with Dawarich! + +Questions? Just reply to this email – I'm here to help. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/post_trial_reminder_late.html.erb b/app/views/users_mailer/post_trial_reminder_late.html.erb new file mode 100644 index 00000000..f347ecb0 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_late.html.erb @@ -0,0 +1,51 @@ + + + + + + + +
+
+

πŸ“ Your Location Data is Waiting

+
+
+

Hi <%= @user.email %>, this is Evgenii from Dawarich.

+ +
+

It's been a week since your Dawarich trial ended.

+
+ +

Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.

+ +

πŸ—ΊοΈ Here's what's waiting for you:

+
    +
  • All your location data, preserved and ready
  • +
  • Reliving your travels through detailed maps and insights
  • +
  • Privacy-first approach – your data stays yours
  • +
  • Beautiful visualizations of your travel patterns
  • +
  • Regular updates and new features
  • +
+ + Return to Dawarich + +

This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.

+ +

Thank you for giving Dawarich a try. I hope to see you again soon!

+ +

Safe travels,
+ Evgenii from Dawarich

+ +

P.S. If you have any questions or need assistance, just hit reply – I'm here to help!

+
+
+ + diff --git a/app/views/users_mailer/post_trial_reminder_late.text.erb b/app/views/users_mailer/post_trial_reminder_late.text.erb new file mode 100644 index 00000000..d43db950 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_late.text.erb @@ -0,0 +1,26 @@ +πŸ“ Your Location Data is Waiting + +Hi <%= @user.email %>, this is Evgenii from Dawarich. + +It's been a week since your Dawarich trial ended. + +Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time. + +πŸ—ΊοΈ Here's what's waiting for you: +- All your location data, preserved and ready +- Reliving your travels through detailed maps and insights +- Privacy-first approach – your data stays yours +- Beautiful visualizations of your travel patterns +- Integration with popular location apps and services +- Regular updates and new features + +Return to Dawarich: https://my.dawarich.app + +This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back. + +Thank you for giving Dawarich a try. I hope to see you again soon! + +Safe travels, +Evgenii from Dawarich + +P.S. If you have any questions or need assistance, just hit reply – I'm here to help! diff --git a/app/views/users_mailer/trial_expired.html.erb b/app/views/users_mailer/trial_expired.html.erb index 3294b88b..6407fdbf 100644 --- a/app/views/users_mailer/trial_expired.html.erb +++ b/app/views/users_mailer/trial_expired.html.erb @@ -17,13 +17,13 @@

πŸ”’ Your Trial Has Expired

-

Hi <%= @user.email %>,

+

Hi <%= @user.email %>, this is Evgenii from Dawarich.

Your 7-day Dawarich trial has ended.

-

Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.

+

Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.

Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.

@@ -40,7 +40,7 @@

Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!

-

We'd love to have you back as a subscriber.

+

I'd love to have you back as a subscriber.

Best regards,
Evgenii from Dawarich

diff --git a/app/views/users_mailer/trial_expired.text.erb b/app/views/users_mailer/trial_expired.text.erb index d43178f3..338f0899 100644 --- a/app/views/users_mailer/trial_expired.text.erb +++ b/app/views/users_mailer/trial_expired.text.erb @@ -1,10 +1,10 @@ πŸ”’ Your Trial Has Expired -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. Your 7-day Dawarich trial has ended. -Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week. +Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week. Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich. @@ -19,7 +19,7 @@ Subscribe to continue: https://my.dawarich.app Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off! -We'd love to have you back as a subscriber. +I'd love to have you back as a subscriber. Best regards, Evgenii from Dawarich diff --git a/app/views/users_mailer/trial_expires_soon.html.erb b/app/views/users_mailer/trial_expires_soon.html.erb index c1e5ff6e..a0ac2f3f 100644 --- a/app/views/users_mailer/trial_expires_soon.html.erb +++ b/app/views/users_mailer/trial_expires_soon.html.erb @@ -17,13 +17,13 @@

⏰ Your Trial Expires Soon

-

Hi <%= @user.email %>,

+

Hi <%= @user.email %>, this is Evgenii from Dawarich.

⚠️ Important: Your Dawarich trial expires in just 2 days!

-

We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.

+

I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.

To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.

@@ -40,7 +40,7 @@

Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!

-

Questions? Drop us a message at hi@dawarich.app or just reply to this email.

+

Questions? Drop me a message at hi@dawarich.app or just reply to this email.

Best regards,
Evgenii from Dawarich

diff --git a/app/views/users_mailer/trial_expires_soon.text.erb b/app/views/users_mailer/trial_expires_soon.text.erb index c5f7352e..6b15ef3a 100644 --- a/app/views/users_mailer/trial_expires_soon.text.erb +++ b/app/views/users_mailer/trial_expires_soon.text.erb @@ -1,10 +1,10 @@ ⏰ Your Trial Expires Soon -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. ⚠️ Important: Your Dawarich trial expires in just 2 days! -We hope you've enjoyed exploring your location data with Dawarich over the past 5 days. +I hope you've enjoyed exploring your location data with Dawarich over the past 5 days. To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan. @@ -19,7 +19,7 @@ Subscribe now: https://my.dawarich.app Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich! -Questions? Drop us a message at hi@dawarich.app +Questions? Drop me a message at hi@dawarich.app or just reply to this email. Best regards, Evgenii from Dawarich diff --git a/app/views/users_mailer/welcome.html.erb b/app/views/users_mailer/welcome.html.erb index 07f80721..c3c34c82 100644 --- a/app/views/users_mailer/welcome.html.erb +++ b/app/views/users_mailer/welcome.html.erb @@ -16,9 +16,9 @@

Welcome to Dawarich!

-

Hi <%= @user.email %>,

+

Hi <%= @user.email %>, this is Evgenii from Dawarich.

-

Welcome to Dawarich! We're excited to have you on board.

+

Welcome to Dawarich! I'm excited to have you on board.

Your 7-day free trial has started. During this time, you can:

    @@ -30,7 +30,7 @@ Start Exploring Dawarich -

    If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.

    +

    If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.

    Happy tracking!
    Evgenii from Dawarich

    diff --git a/app/views/users_mailer/welcome.text.erb b/app/views/users_mailer/welcome.text.erb index 8cbf42d2..5870f372 100644 --- a/app/views/users_mailer/welcome.text.erb +++ b/app/views/users_mailer/welcome.text.erb @@ -1,8 +1,8 @@ Welcome to Dawarich! -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. -Welcome to Dawarich! We're excited to have you on board. +Welcome to Dawarich! I'm excited to have you on board. Your 7-day free trial has started. During this time, you can: - Track your location data @@ -12,7 +12,7 @@ Your 7-day free trial has started. During this time, you can: Start exploring Dawarich: https://my.dawarich.app -If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email. +If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email. Happy tracking! Evgenii from Dawarich diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb index f32f31a6..79483db7 100644 --- a/spec/jobs/cache/preheating_job_spec.rb +++ b/spec/jobs/cache/preheating_job_spec.rb @@ -3,9 +3,7 @@ require 'rails_helper' RSpec.describe Cache::PreheatingJob do - before do - Rails.cache.clear - end + before { Rails.cache.clear } describe '#perform' do let!(:user1) { create(:user) } @@ -59,11 +57,11 @@ RSpec.describe Cache::PreheatingJob do it 'handles users with no points gracefully' do user_no_points = create(:user) - + expect { described_class.new.perform }.not_to raise_error - + cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats") expect(cached_stats).to eq({ geocoded: 0, without_data: 0 }) end end -end \ No newline at end of file +end diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb index 11789e2b..eed70c31 100644 --- a/spec/mailers/users_mailer_spec.rb +++ b/spec/mailers/users_mailer_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "rails_helper" +require 'rails_helper' RSpec.describe UsersMailer, type: :mailer do let(:user) { create(:user, email: 'test@example.com') } @@ -9,43 +9,43 @@ RSpec.describe UsersMailer, type: :mailer do stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) end - describe "welcome" do + describe 'welcome' do let(:mail) { UsersMailer.with(user: user).welcome } - it "renders the headers" do - expect(mail.subject).to eq("Welcome to Dawarich!") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('Welcome to Dawarich!') + expect(mail.to).to eq(['test@example.com']) end - it "renders the body" do - expect(mail.body.encoded).to match("test@example.com") + it 'renders the body' do + expect(mail.body.encoded).to match('test@example.com') end end - describe "explore_features" do + describe 'explore_features' do let(:mail) { UsersMailer.with(user: user).explore_features } - it "renders the headers" do - expect(mail.subject).to eq("Explore Dawarich features!") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('Explore Dawarich features!') + expect(mail.to).to eq(['test@example.com']) end end - describe "trial_expires_soon" do + describe 'trial_expires_soon' do let(:mail) { UsersMailer.with(user: user).trial_expires_soon } - it "renders the headers" do - expect(mail.subject).to eq("⚠️ Your Dawarich trial expires in 2 days") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days') + expect(mail.to).to eq(['test@example.com']) end end - describe "trial_expired" do + describe 'trial_expired' do let(:mail) { UsersMailer.with(user: user).trial_expired } - it "renders the headers" do - expect(mail.subject).to eq("πŸ’” Your Dawarich trial expired") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('πŸ’” Your Dawarich trial expired') + expect(mail.to).to eq(['test@example.com']) end end end diff --git a/spec/queries/stats_query_spec.rb b/spec/queries/stats_query_spec.rb index 6347e4e6..8efcbb81 100644 --- a/spec/queries/stats_query_spec.rb +++ b/spec/queries/stats_query_spec.rb @@ -3,9 +3,8 @@ require 'rails_helper' RSpec.describe StatsQuery do - before do - Rails.cache.clear - end + before { Rails.cache.clear } + describe '#points_stats' do subject(:points_stats) { described_class.new(user).points_stats } @@ -14,11 +13,13 @@ RSpec.describe StatsQuery do context 'when user has no points' do it 'returns zero counts for all statistics' do - expect(points_stats).to eq({ - total: 0, - geocoded: 0, - without_data: 0 - }) + expect(points_stats).to eq( + { + total: 0, + geocoded: 0, + without_data: 0 + } + ) end end @@ -48,11 +49,13 @@ RSpec.describe StatsQuery do end it 'returns correct counts for all statistics' do - expect(points_stats).to eq({ - total: 3, - geocoded: 2, - without_data: 1 - }) + expect(points_stats).to eq( + { + total: 3, + geocoded: 2, + without_data: 1 + } + ) end context 'when another user has points' do @@ -67,11 +70,13 @@ RSpec.describe StatsQuery do end it 'only counts points for the specified user' do - expect(points_stats).to eq({ - total: 3, - geocoded: 2, - without_data: 1 - }) + expect(points_stats).to eq( + { + total: 3, + geocoded: 2, + without_data: 1 + } + ) end end end @@ -86,11 +91,13 @@ RSpec.describe StatsQuery do end it 'returns correct statistics' do - expect(points_stats).to eq({ - total: 5, - geocoded: 5, - without_data: 0 - }) + expect(points_stats).to eq( + { + total: 5, + geocoded: 5, + without_data: 0 + } + ) end end @@ -104,11 +111,13 @@ RSpec.describe StatsQuery do end it 'returns correct statistics' do - expect(points_stats).to eq({ - total: 3, - geocoded: 3, - without_data: 3 - }) + expect(points_stats).to eq( + { + total: 3, + geocoded: 3, + without_data: 3 + } + ) end end @@ -122,11 +131,13 @@ RSpec.describe StatsQuery do end it 'returns correct statistics' do - expect(points_stats).to eq({ - total: 4, - geocoded: 0, - without_data: 0 - }) + expect(points_stats).to eq( + { + total: 4, + geocoded: 0, + without_data: 0 + } + ) end end @@ -171,4 +182,4 @@ RSpec.describe StatsQuery do end end end -end \ No newline at end of file +end diff --git a/spec/services/cache/clean_spec.rb b/spec/services/cache/clean_spec.rb index 84c0b9f7..38ec04b9 100644 --- a/spec/services/cache/clean_spec.rb +++ b/spec/services/cache/clean_spec.rb @@ -3,86 +3,88 @@ require 'rails_helper' RSpec.describe Cache::Clean do - before do - Rails.cache.clear - end + before { Rails.cache.clear } describe '.call' do let!(:user1) { create(:user) } let!(:user2) { create(:user) } + let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" } + let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" } + let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" } + let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" } before do # Set up cache entries that should be cleaned Rails.cache.write('cache_jobs_scheduled', true) Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0') - Rails.cache.write("dawarich/user_#{user1.id}_years_tracked", { 2023 => ['Jan', 'Feb'] }) - Rails.cache.write("dawarich/user_#{user2.id}_years_tracked", { 2023 => ['Mar', 'Apr'] }) - Rails.cache.write("dawarich/user_#{user1.id}_points_geocoded_stats", { geocoded: 5, without_data: 2 }) - Rails.cache.write("dawarich/user_#{user2.id}_points_geocoded_stats", { geocoded: 3, without_data: 1 }) + Rails.cache.write(user_1_years_tracked_key, { 2023 => %w[Jan Feb] }) + Rails.cache.write(user_2_years_tracked_key, { 2023 => %w[Mar Apr] }) + Rails.cache.write(user_1_points_geocoded_stats_key, { geocoded: 5, without_data: 2 }) + Rails.cache.write(user_2_points_geocoded_stats_key, { geocoded: 3, without_data: 1 }) end it 'deletes control flag cache' do expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true - + described_class.call - + expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false end it 'deletes version cache' do expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true - + described_class.call - + expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false end it 'deletes years tracked cache for all users' do - expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true - expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true - + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true + described_class.call - - expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be false - expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be false + + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be false + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be false end it 'deletes points geocoded stats cache for all users' do - expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true - expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true - + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true + described_class.call - - expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be false - expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be false + + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be false + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be false end it 'logs cache cleaning process' do expect(Rails.logger).to receive(:info).with('Cleaning cache...') expect(Rails.logger).to receive(:info).with('Cache cleaned') - + described_class.call end it 'handles users being added during execution gracefully' do # Create a user that will be found during the cleaning process user3 = nil - + allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block| # Create a new user while iterating - this should not cause errors user3 = create(:user) Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] }) Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 }) - + # Continue with the original block [user1, user2].each(&block) end - + expect { described_class.call }.not_to raise_error - + # The new user's cache should still exist since it wasn't processed expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true end end -end \ No newline at end of file +end From ea2fbfb3257f302e72b371a782cd2dc16b2b5396 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 13 Sep 2025 15:58:36 +0200 Subject: [PATCH 3/5] Fix caching job specs --- app/jobs/users/mailer_sending_job.rb | 11 +++- spec/jobs/cache/preheating_job_spec.rb | 69 ++++++++++++++-------- spec/jobs/users/mailer_sending_job_spec.rb | 24 -------- spec/mailers/users_mailer_spec.rb | 18 ++++++ 4 files changed, 72 insertions(+), 50 deletions(-) diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb index ae8b80dc..742db9eb 100644 --- a/app/jobs/users/mailer_sending_job.rb +++ b/app/jobs/users/mailer_sending_job.rb @@ -7,7 +7,11 @@ class Users::MailerSendingJob < ApplicationJob user = User.find(user_id) if should_skip_email?(user, email_type) - Rails.logger.info "Skipping #{email_type} email for user #{user_id} - #{skip_reason(user, email_type)}" + ExceptionReporter.call( + 'Users::MailerSendingJob', + "Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}" + ) + return end @@ -15,7 +19,10 @@ class Users::MailerSendingJob < ApplicationJob UsersMailer.with(params).public_send(email_type).deliver_later rescue ActiveRecord::RecordNotFound - Rails.logger.warn "User with ID #{user_id} not found. Skipping #{email_type} email." + ExceptionReporter.call( + 'Users::MailerSendingJob', + "User with ID #{user_id} not found. Skipping #{email_type} email." + ) end private diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb index 79483db7..e148cee5 100644 --- a/spec/jobs/cache/preheating_job_spec.rb +++ b/spec/jobs/cache/preheating_job_spec.rb @@ -10,6 +10,10 @@ RSpec.describe Cache::PreheatingJob do let!(:user2) { create(:user) } let!(:import1) { create(:import, user: user1) } let!(:import2) { create(:import, user: user2) } + let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" } + let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" } + let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" } + let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" } before do create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current) @@ -17,42 +21,59 @@ RSpec.describe Cache::PreheatingJob do end it 'preheats years_tracked cache for all users' do - expect(Rails.cache).to receive(:write).with( - "dawarich/user_#{user1.id}_years_tracked", - anything, - expires_in: 1.day - ) - expect(Rails.cache).to receive(:write).with( - "dawarich/user_#{user2.id}_years_tracked", - anything, - expires_in: 1.day - ) + # Clear cache before test to ensure clean state + Rails.cache.clear described_class.new.perform + + # Verify that cache keys exist after job runs + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true + + # Verify the cached data is reasonable + user1_years = Rails.cache.read(user_1_years_tracked_key) + user2_years = Rails.cache.read(user_2_years_tracked_key) + + expect(user1_years).to be_an(Array) + expect(user2_years).to be_an(Array) end it 'preheats points_geocoded_stats cache for all users' do - expect(Rails.cache).to receive(:write).with( - "dawarich/user_#{user1.id}_points_geocoded_stats", - { geocoded: 3, without_data: 0 }, - expires_in: 1.day - ) - expect(Rails.cache).to receive(:write).with( - "dawarich/user_#{user2.id}_points_geocoded_stats", - { geocoded: 2, without_data: 0 }, - expires_in: 1.day - ) + # Clear cache before test to ensure clean state + Rails.cache.clear described_class.new.perform + + # Verify that cache keys exist after job runs + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true + + # Verify the cached data has the expected structure + user1_stats = Rails.cache.read(user_1_points_geocoded_stats_key) + user2_stats = Rails.cache.read(user_2_points_geocoded_stats_key) + + expect(user1_stats).to be_a(Hash) + expect(user1_stats).to have_key(:geocoded) + expect(user1_stats).to have_key(:without_data) + expect(user1_stats[:geocoded]).to eq(3) + + expect(user2_stats).to be_a(Hash) + expect(user2_stats).to have_key(:geocoded) + expect(user2_stats).to have_key(:without_data) + expect(user2_stats[:geocoded]).to eq(2) end it 'actually writes to cache' do described_class.new.perform - expect(Rails.cache.exist?("dawarich/user_#{user1.id}_years_tracked")).to be true - expect(Rails.cache.exist?("dawarich/user_#{user1.id}_points_geocoded_stats")).to be true - expect(Rails.cache.exist?("dawarich/user_#{user2.id}_years_tracked")).to be true - expect(Rails.cache.exist?("dawarich/user_#{user2.id}_points_geocoded_stats")).to be true + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_1_countries_visited_key)).to be true + expect(Rails.cache.exist?(user_1_cities_visited_key)).to be true + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_2_countries_visited_key)).to be true + expect(Rails.cache.exist?(user_2_cities_visited_key)).to be true end it 'handles users with no points gracefully' do diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb index 2df6afa2..92781395 100644 --- a/spec/jobs/users/mailer_sending_job_spec.rb +++ b/spec/jobs/users/mailer_sending_job_spec.rb @@ -117,28 +117,4 @@ RSpec.describe Users::MailerSendingJob, type: :job do end end end - - describe '#trial_related_email?' do - subject { described_class.new } - - it 'returns true for trial_expires_soon' do - expect(subject.send(:trial_related_email?, 'trial_expires_soon')).to be true - end - - it 'returns true for trial_expired' do - expect(subject.send(:trial_related_email?, 'trial_expired')).to be true - end - - it 'returns false for welcome' do - expect(subject.send(:trial_related_email?, 'welcome')).to be false - end - - it 'returns false for explore_features' do - expect(subject.send(:trial_related_email?, 'explore_features')).to be false - end - - it 'returns false for unknown email types' do - expect(subject.send(:trial_related_email?, 'unknown_email')).to be false - end - end end diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb index eed70c31..9d0195e3 100644 --- a/spec/mailers/users_mailer_spec.rb +++ b/spec/mailers/users_mailer_spec.rb @@ -48,4 +48,22 @@ RSpec.describe UsersMailer, type: :mailer do expect(mail.to).to eq(['test@example.com']) end end + + describe 'post_trial_reminder_early' do + let(:mail) { UsersMailer.with(user: user).post_trial_reminder_early } + + it 'renders the headers' do + expect(mail.subject).to eq('πŸš€ Still interested in Dawarich? Subscribe now!') + expect(mail.to).to eq(['test@example.com']) + end + end + + describe 'post_trial_reminder_late' do + let(:mail) { UsersMailer.with(user: user).post_trial_reminder_late } + + it 'renders the headers' do + expect(mail.subject).to eq('πŸ“ Your location data is waiting - Subscribe to Dawarich') + expect(mail.to).to eq(['test@example.com']) + end + end end From dd31563653c946d2528fa196ce2c76896d373f8e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 13 Sep 2025 16:05:52 +0200 Subject: [PATCH 4/5] Add missing vars to specs --- spec/jobs/cache/preheating_job_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb index e148cee5..fc809194 100644 --- a/spec/jobs/cache/preheating_job_spec.rb +++ b/spec/jobs/cache/preheating_job_spec.rb @@ -14,6 +14,10 @@ RSpec.describe Cache::PreheatingJob do let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" } let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" } let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" } + let(:user_1_countries_visited_key) { "dawarich/user_#{user1.id}_countries_visited" } + let(:user_2_countries_visited_key) { "dawarich/user_#{user2.id}_countries_visited" } + let(:user_1_cities_visited_key) { "dawarich/user_#{user1.id}_cities_visited" } + let(:user_2_cities_visited_key) { "dawarich/user_#{user2.id}_cities_visited" } before do create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current) From 662d819f476ca410133a0a62f6947bb375aea339 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sat, 13 Sep 2025 16:10:46 +0200 Subject: [PATCH 5/5] Update spec name --- spec/requests/api/v1/subscriptions_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/api/v1/subscriptions_spec.rb b/spec/requests/api/v1/subscriptions_spec.rb index 19fea01c..a034843e 100644 --- a/spec/requests/api/v1/subscriptions_spec.rb +++ b/spec/requests/api/v1/subscriptions_spec.rb @@ -96,7 +96,7 @@ RSpec.describe 'Api::V1::Subscriptions', type: :request do JWT.encode({ user_id: 'invalid', status: nil }, jwt_secret, 'HS256') end - it 'returns unprocessable_entity error with invalid data message' do + it 'returns unprocessable_content error with invalid data message' do allow(Subscription::DecodeJwtToken).to receive(:new).with(token) .and_raise(ArgumentError.new('Invalid token data'))