独家

Steady Pace <mv-steady-pace>

目标节奏规划,告诉你从今天起需要保持的稳定速率,而不只是一个百分比:储蓄目标、销售指标、阅读或学习目标、筹款、迭代范围、训练计划。

文化出处

《龟兔赛跑》,伊索(约公元前 6 世纪,寓言)。跑得快的一方冲在前面,停下来休息,结果输了,而慢的一方全程保持匀速,最终获胜:重要的是相对于终点的节奏,而不是速度。UI 会以从开始到截止日期的匀速为标准衡量你的进度,告诉你从今天起需要保持的稳定速度,并在进度呈现爆发后停滞的模式时温和地指出来。

工作原理

目标进度规划,告诉你从今天起需要保持的稳定速度,而不只是一个百分比:储蓄目标、销售配额、阅读或学习目标、筹款、迭代范围、训练计划。传入目标值、开始日期、截止日期和带日期的记录(金额,或配合 entry-type="total" 的累计总额),它会将你当前的位置与从开始到截止日期的匀速“稳定线”进行比较:以单位和天数表示领先或落后(“$194 behind the steady line”、“4 days behind”);从今天起需要的速度,向上取整,确保按此执行总能按时完成(“$54/day to reach $4,000 by Nov 1”);最近 7 天和 14 天是否足够(带有所需速度刻度的小型计量条和文字结论);以及按最近的速度你会在何时完成(“you’d finish around Nov 15, 14 days after the deadline”)。当大部分进度集中在一两天、随后一周毫无动静时,一条友善的提示会指出这一点,并给出保持稳定所需的每日小额目标,从不发出警告。chart 变体绘制阶梯状的累计实际进度、稳定线、从今天起所需的速度线,以及标注完成日期的最近速度预测线,支持悬停读数,并在展开区域中提供真实的数据表。假设分析滑块可以在两种规则下测试另一个每日数额:保持截止日期(届时你会达到多少)或调整日期(你会在何时完成)。支持仅计工作日并排除节假日、按日/周/月显示、相对日期(“+37d”、“-2w”)以及午夜自动刷新。提供三种变体:内联句子、卡片和完整图表。

分类数据展示
类型Web Component(<mv-steady-pace>)
状态稳定版
套件会自我解释的数字
同时安装slider
Keywordsexclusive, culture, pacing, goal, target, deadline, quota, savings, fundraising, burn-up, burndown, run-rate, required-rate, habit, streak, training-plan, sprint, what-if, chart, workdays, projection

When to use

  • A savings, fundraising or budget goal should say how much to put in per day or week from now on, not only how far along it is
  • A sales team tracks a quarterly quota in workdays and needs to know if it is ahead or behind and what weekly pace closes the gap
  • A reading, learning or training plan should flag progress that came in bursts and suggest a small steady daily amount instead
  • A goals list or dashboard row needs a one-line pacing status with the required rate, in a sentence rather than a chart

Avoid when

  • There is no deadline: the value only needs to show how far along a task is → use Progress instead
  • The question is when an open-ended metric reaches a goal on its trend, with levers and an uncertainty range → use Yet To Come instead
  • People repeat the same task and want to race their own previous attempt rather than a calendar → use Ghost Run instead

安装

node scripts/add.mjs steady-pace --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["steady-pace"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/slider/slider.js, components/slider/slider.css, core/motion.js, core/observe.js, components/steady-pace/steady-pace.js, components/steady-pace/steady-pace.css。

用法

快速开始,最小可运行标记:

<mv-steady-pace label="Emergency fund" format="currency" target="4000" start="-45d" deadline="+37d">
  <script type="application/json" data-entries>[["-44d", 300], ["-31d", 300], ["-17d", 300], ["-3d", 300]]</script>
</mv-steady-pace>

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="sp-demo" style="width:min(100%,68rem);margin-inline:auto">
  <style>
    #sp-demo { display:grid; gap:1.75rem }
    #sp-demo .spd-bar { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1rem }
    #sp-demo .spd-bar h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
    #sp-demo .spd-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #sp-demo .spd-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem }
    #sp-demo .spd-col { display:grid; gap:.75rem; min-width:0; align-content:start }
    #sp-demo .spd-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1.5rem; align-items:start }
    #sp-demo .spd-log { display:flex; align-items:center; gap:.5rem; min-width:0; color:var(--mv-fg-muted); font-size:.75rem }
    #sp-demo .spd-log span:last-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #sp-demo .spd-goals { margin:0; padding:0; list-style:none; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #sp-demo .spd-goals li { display:grid; grid-template-columns:minmax(9rem,13rem) minmax(0,1fr); align-items:center; gap:.5rem 1.25rem; padding:.875rem 1.125rem; font-size:.875rem }
    #sp-demo .spd-goals li + li { border-top:1px solid var(--mv-border) }
    #sp-demo .spd-goal { display:grid; gap:.125rem; min-width:0 }
    #sp-demo .spd-goal strong { font-weight:600 }
    #sp-demo .spd-goal span { color:var(--mv-fg-muted); font-size:.75rem }
    @media (max-width:52rem) {
      #sp-demo .spd-row { grid-template-columns:minmax(0,1fr) }
      #sp-demo .spd-goals li { grid-template-columns:minmax(0,1fr) }
    }
  </style>

  <!-- Main: a savings goal, full chart with a what-if -->
  <section class="spd-col" aria-labelledby="spd-main-h">
    <div class="spd-bar">
      <div>
        <h3 id="spd-main-h">Emergency fund</h3>
        <p>Personal finance app · deposits since mid-August</p>
      </div>
      <div class="spd-actions">
        <mv-segmented id="spd-view" aria-label="View" value="chart">
          <button value="inline">Inline</button>
          <button value="card">Card</button>
          <button value="chart">Chart</button>
        </mv-segmented>
        <label class="mv-choice" data-control="end">
          <input type="checkbox" role="switch" class="mv-switch" id="spd-workdays">
          <span class="mv-choice-text"><span class="mv-choice-title">Workdays only</span></span>
        </label>
        <button class="mv-button" data-size="sm" type="button" id="spd-log-btn">Log $75 today</button>
      </div>
    </div>
    <mv-steady-pace id="spd-main" label="Save $4,000 for an emergency fund" format="currency" target="4000" start="-45d" deadline="+37d" variant="chart" what-if="fixed">
      <script type="application/json" data-entries>[
        ["-44d", 300, "Paycheck"], ["-38d", 180], ["-31d", 300, "Paycheck"], ["-27d", 150], ["-24d", 90],
        ["-20d", 100], ["-17d", 300, "Paycheck"], ["-12d", 120], ["-9d", 60], ["-3d", 300, "Paycheck"], ["-1d", 75]
      ]</script>
    </mv-steady-pace>
    <div class="spd-log" aria-hidden="true"><span class="mv-badge" data-variant="secondary" data-shape="pill" id="spd-event">mv-pace-change</span><span id="spd-last">Waiting for an event…</span></div>
  </section>

  <div class="spd-row">
    <!-- A sales quota counted in workdays, shown per week; two big deals then a quiet stretch -->
    <section class="spd-col" aria-labelledby="spd-quota-h">
      <div class="spd-bar"><div><h3 id="spd-quota-h">Q4 sales quota</h3><p>Workdays only · pace per week · burst detection</p></div></div>
      <mv-steady-pace label="Team quota" format="currency" target="180000" start="-24d" deadline="+60d" working-days per="week">
        <script type="application/json" data-entries>[
          ["-22d", 42000, "Kestrel renewal"], ["-19d", 38500, "Globex expansion"], ["-15d", 4200], ["-9d", 6500], ["-6d", 2800], ["-2d", 3600]
        ]</script>
      </mv-steady-pace>
    </section>

    <!-- A learning goal in custom units, with the "move the date" what-if -->
    <section class="spd-col" aria-labelledby="spd-words-h">
      <div class="spd-bar"><div><h3 id="spd-words-h">Spanish vocabulary</h3><p>Custom unit · what-if that moves the date</p></div></div>
      <mv-steady-pace label="Learn 1,500 words" unit="words" target="1500" start="-20d" deadline="+10d" what-if="extend">
        <script type="application/json" data-entries>[
          ["-20d", 60], ["-19d", 45], ["-18d", 70], ["-17d", 50], ["-15d", 80], ["-14d", 40], ["-13d", 55], ["-11d", 65],
          ["-10d", 50], ["-8d", 75], ["-7d", 60], ["-6d", 45], ["-4d", 90], ["-3d", 70], ["-2d", 55], ["-1d", 60], ["today", 70]
        ]</script>
      </mv-steady-pace>
    </section>
  </div>

  <!-- Inline sentences inside a goals list -->
  <section class="spd-col" aria-labelledby="spd-goals-h">
    <div class="spd-bar"><div><h3 id="spd-goals-h">Team goals</h3><p>Inline variant: one status and one sentence per row</p></div></div>
    <ul class="spd-goals">
      <li>
        <span class="spd-goal"><strong>Sprint 42 scope</strong><span>Engineering · workdays</span></span>
        <mv-steady-pace variant="inline" unit="points" target="40" start="-6d" deadline="+7d" working-days>
          <script type="application/json" data-entries>[["-5d", 5], ["-4d", 3], ["-2d", 4], ["-1d", 4]]</script>
        </mv-steady-pace>
      </li>
      <li>
        <span class="spd-goal"><strong>Community fundraiser</strong><span>Riverside food bank</span></span>
        <mv-steady-pace variant="inline" format="currency" target="25000" start="-30d" deadline="+30d">
          <script type="application/json" data-entries>[["-29d", 6000, "Launch event"], ["-27d", 1200], ["-20d", 800], ["-14d", 650], ["-9d", 400], ["-4d", 750]]</script>
        </mv-steady-pace>
      </li>
      <li>
        <span class="spd-goal"><strong>Marathon training block</strong><span>Aiko Tanaka · distance</span></span>
        <mv-steady-pace variant="inline" unit="km" target="400" start="-50d" deadline="+6d">
          <script type="application/json" data-entries>[["-49d", 60], ["-40d", 70], ["-31d", 75], ["-22d", 80], ["-12d", 70], ["-3d", 45]]</script>
        </mv-steady-pace>
      </li>
    </ul>
  </section>

  <script type="module">
    await customElements.whenDefined("mv-steady-pace");
    const main = document.getElementById("spd-main");
    const badge = document.getElementById("spd-event");
    const last = document.getElementById("spd-last");

    main.addEventListener("mv-pace-change", (e) => {
      const { tier, previous, statement, label } = e.detail;
      if (previous === null) return;
      badge.textContent = "mv-pace-change";
      badge.dataset.variant = tier === "ahead" || tier === "done" ? "success" : tier === "on-pace" ? "info" : "warning";
      last.textContent = `${previous} → ${tier}: ${label}. ${statement}`;
    });
    main.addEventListener("mv-whatif", (e) => {
      badge.textContent = "mv-whatif";
      badge.dataset.variant = "secondary";
      last.textContent = e.detail.statement;
    });

    document.getElementById("spd-log-btn").addEventListener("click", () => main.add(75));
    document.getElementById("spd-workdays").addEventListener("change", (e) => { main.workingDays = e.target.checked; });
    document.getElementById("spd-view").addEventListener("mv-change", (e) => { main.variant = e.detail.value; });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
targetnumberThe goal, in the same unit as the entries (e.g. 4000 for $4,000, 1500 words). Required.
deadlinedateLast day of the plan (inclusive). Accepts YYYY-MM-DD, any Date.parse string, "today" or a date relative to today: "+37d", "+6w", "+3m". Required.
startdateFirst day of the plan, same formats. Defaults to the first entry’s date (or today). The steady line starts here.
start-valuenumber0Amount already there before any entry (e.g. money already saved). With entry-type="total", only used when no reading exists on or before the start.
entry-typeamount | totalamountamount: each entry adds to the total (deposits, pages read, deals closed). total: each entry is a reading of the running total (an account balance, a fundraiser page); several readings on one day keep the last.
todaydatePins “today” (server-rendered pages, reports as of a date, tests). Default: the user’s local date, refreshed at midnight while the page is open.
working-daysbooleanCounts Monday to Friday only (minus the holidays property): the steady line is flat on days off, gaps read “3 workdays behind” and the pace is per workday.
variantinline | card | chartcardinline: status chip + one sentence, for lists and table rows. card: the needed pace, a rail comparing goal done with today’s steady mark, recent pace and notes. chart: the card beside a chart with legend and a data table.
what-iffixed | extendAdds the what-if slider (card and chart variants). fixed keeps the deadline and reports what you would have by then; extend lets the date move and reports when you would finish. Users can switch between the two with the built-in toggle, which updates this attribute.
perday | week | monthdayPeriod in which rates are written (“$10,756/week”). With working-days, a week is 5 workdays and a month 21.75.
labelstringName of the goal, shown in the header and used as the accessible name of the group and in announcements.
formatnumber | currencynumberHow amounts are written (Intl.NumberFormat).
currencyISO 4217 codeUSDCurrency for format="currency".
unitstringSuffix for plain numbers (“words”, “km”, “points”); the headline reads “42 words/day”.
localeBCP 47 tagen-USLocale for numbers and dates.
decimalsnumberFixed number of decimals (default: automatic, fewer for large values). Paces are always rounded up at that precision.
tolerancenumber (days)1Gap, in days of steady progress, under which you are “On pace” rather than ahead or behind.
strainnumber1.5When behind and the pace needed from today exceeds this multiple of the original steady rate, the tier becomes far-behind (stronger chip, same kind wording).
recent-daysnumber14Window of the recent pace used for the projection; the card lists the last 7 days and this window. If nothing is logged today yet, the window ends yesterday so a morning never counts against you.
data-tier / data-burst / data-whatif-touched / data-variantset by the componentCurrent tier (not-started | ahead | on-pace | behind | far-behind | done | missed), present when a burst-then-stall pattern is detected, present once the what-if slider moved, resolved variant.

Properties

Name类型Description
entriesArray<[date, amount, note?] | { date, amount, note? }>Dated entries (any date format accepted by the attributes, including relative ones). Can also come from a child <script type="application/json" data-entries>. Reading returns [{ date: "YYYY-MM-DD", amount, note }].
holidaysArray<date>Days skipped in working-days mode (also from <script type="application/json" data-holidays>).
paceobject | nullRead-only snapshot, the same object as mv-pace-change’s detail: { tier, label, statement, current, target, remaining, start, deadline, today, per, steadyRate, steadyToday, gap: { units, days, workingDays }, required, daysLeft, recent: [{ days, from, to, pace, ratio, verdict }], projection: { pace, finish, daysVsDeadline }, reachedOn, burst: { days, share } | null }. Dates are YYYY-MM-DD, rates are per the per period.
statusstring | nullCurrent tier (read-only).
whatIfPacenumber | nullWhat-if amount per period. Set it to drive the slider from code; null follows the recent pace again.
formatter(value, { signed, rate }) => stringReplaces format / currency / unit / decimals for every amount.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement, with {placeholders} (e.g. { behind: "{days} to catch up", day: "day", days: "days" }). English defaults; see the STRINGS table at the top of steady-pace.js for all keys.
target / start / deadline / today / startValue / entryType / workingDays / variant / whatIf / per / …reflectedEvery attribute has a matching camelCase property.

Methods

NameDescription
add(amount, date = "today", note?)Logs an entry (a new total with entry-type="total") and recomputes. Returns the new pace snapshot.
refresh()Recomputes, e.g. after the day changed with a pinned today removed.

Events

NameDescription
mv-pace-changeThe status tier changed; also fired once after the first computation with previous: null. detail: the pace snapshot plus { previous }.
mv-whatifThe what-if slider moved, its rule was switched, or whatIfPace was set. detail: { mode: "fixed" | "extend", pace, per, finish (YYYY-MM-DD | null), daysVsDeadline, amountAtDeadline, shortfall, statement }.

Content structure

NameDescription
script[data-entries] / script[data-holidays]Optional JSON children for plain-HTML use; the component’s own DOM is appended after them.

CSS classes

NameDescription
mv-steady-pace-root[data-variant] / -head / -label / -chip[data-tier]Frame, header, goal label and status chip (icon + words).
mv-steady-pace-sentenceThe one-line sentence of the inline variant.
mv-steady-pace-need / -need-value / -need-per / -need-textHeadline: the pace needed from today, its period and the sentence under it.
mv-steady-pace-rail / -rail-fill / -rail-gap / -rail-mark / -gapRail: goal done (fill), today’s steady mark and the hatched gap between them (ahead: solid stripes, behind: outlined stripes); the gap sentence below.
mv-steady-pace-recent / -recent-row[data-verdict] / -meter / -verdict / -projectionRecent pace rows with a meter and a notch at the needed pace, the verdict word, then the projection sentence.
mv-steady-pace-noteThe burst-then-stall note.
mv-steady-pace-chart / -plot / -actual / -steady / -needed / -recent-line / -whatif-line / -gap-area / -dot[data-kind] / -finish-label / -legend / -data / -tableChart parts, markers, legend and the data table disclosure.
mv-steady-pace-whatif / -modes / -mode / -whatif-input / -whatif-resultWhat-if group: rule toggle (native radios), native range input (class mv-slider) and result sentence.

CSS variables

NameDefaultDescription
--mv-steady-pace-actualvar(--mv-accent)Actual progress: rail fill, actual line, recent-pace dots, meters.
--mv-steady-pace-steadyvar(--mv-fg-muted)Steady line color.
--mv-steady-pace-aheadvar(--mv-success)Ahead and done tiers, the ahead part of the rail, “Enough” verdicts.
--mv-steady-pace-behindvar(--mv-warning)Behind tiers and the behind part of the rail. There is deliberately no red.
--mv-steady-pace-on-pacevar(--mv-accent)On-pace chip.
--mv-steady-pace-whatifvar(--mv-fg)What-if line, marker and label.
--mv-steady-pace-chart-height13.5remHeight of the plot area.
--mv-steady-pace-paddingvar(--mv-space-5)Inner padding of the card.

Accessibility

The element is a group named by its label. Every status is written in words next to an icon: the chip says “4 days behind”, “On pace” or “Goal reached”, the gap sentence says “$194 behind the steady line”, recent rows end with “Enough”, “Nearly there” or “Less than needed”, and the rail, meters and chart are aria-hidden because the same facts are in the text; colors only reinforce it, and the rail’s ahead and behind parts also differ by pattern (solid versus outlined stripes). The chart’s lines differ by dash pattern and weight, and its full equivalent is a real table (date, added, total, steady line, difference) behind a native <details> disclosure that everyone can open. A single polite live region speaks only when the tier changes (“Emergency fund: On pace. $49/day to reach $4,000 by Nov 1.”), never on the first render, a slider move or a routine update. The what-if uses native controls: a radio group for the rule (keep the deadline or move the date) and <input type="range"> with a visible label, an aria-valuetext with the unit and period, and aria-describedby pointing at the result sentence, so each step is read with its consequence. The burst note and every behind state are phrased as information, never blame, and no red is used. Reduced motion (OS setting or data-motion="reduce"): no chart draw-in, rail and meter changes jump instead of sliding. Forced colors: fills use Highlight, markers CanvasText, the chip keeps a visible outline.

本页面由 AI 翻译。报告翻译问题