익스클루시브
Steady Pace <mv-steady-pace>
단순한 퍼센트가 아니라 오늘부터 필요한 일정한 속도를 알려 주는 목표 페이스 관리입니다. 저축 목표, 영업 할당량, 독서나 학습 목표, 모금, 스프린트 범위, 훈련 계획에 적합합니다.
문화적 레퍼런스
토끼와 거북이, 이솝 (기원전 6세기경, 우화). 빠른 주자는 앞서 달려 나가다 쉬느라 지고, 느린 주자는 끝까지 일정한 속도를 유지해 이깁니다: 중요한 것은 속도가 아니라 결승선에 비춘 페이스입니다. UI는 시작부터 마감까지의 그 일정한 페이스에 비추어 현재 위치를 측정하고, 오늘부터 필요한 일정한 속도를 알려 주며, 진전이 몰아서 이루어진 뒤 정체되었을 때 부드럽게 짚어 줍니다.
작동 방식
백분율만이 아니라 오늘부터 필요한 일정한 속도를 알려 주는 목표 페이스 관리: 저축 목표, 판매 할당량, 독서나 학습 목표, 모금, 스프린트 범위, 훈련 계획. 목표, 시작일, 마감일, 날짜가 있는 기록(금액, 또는 entry-type="total"을 사용한 누적 합계)을 주면 현재 위치를 시작부터 마감까지 이어지는 균등한 “steady line”과 비교합니다: 단위와 일수로 앞섰는지 뒤처졌는지(“$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”)를 알려 줍니다. 진전의 대부분이 하루나 이틀에 몰리고 이후 조용한 한 주가 이어지면, 친절한 메모가 이를 짚고 꾸준함을 유지할 작은 일일 금액을 제시하며, 경고로 표시하는 일은 없습니다. 차트 변형은 누적 실제 진전을 계단형으로 그리고, steady line, 오늘부터 필요한 선, 완료일이 표시된 최근 페이스 예측도 그리며, 호버 판독값과 디스클로저 뒤의 실제 데이터 표를 갖춥니다. what-if 슬라이더는 다른 일일 금액을 두 가지 규칙으로 시험합니다: 마감 유지(그때까지 얼마가 될지) 또는 날짜 이동(언제 끝날지). 공휴일을 반영한 근무일 기준 계산, 일·주·월 단위 표시, 상대 날짜(“+37d”, “-2w”), 자정 새로고침을 지원합니다. 세 가지 변형: 인라인 문장, 카드, 전체 차트.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-steady-pace>) |
| 상태 | 안정 |
| 키트 | 스스로 설명하는 숫자 |
| 함께 설치되는 항목 | slider |
| Keywords | exclusive, 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/marvelousMarvelous 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 | 유형 | Default | Description |
|---|---|---|---|
target | number | The goal, in the same unit as the entries (e.g. 4000 for $4,000, 1500 words). Required. | |
deadline | date | Last day of the plan (inclusive). Accepts YYYY-MM-DD, any Date.parse string, "today" or a date relative to today: "+37d", "+6w", "+3m". Required. | |
start | date | First day of the plan, same formats. Defaults to the first entry’s date (or today). The steady line starts here. | |
start-value | number | 0 | Amount 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-type | amount | total | amount | amount: 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. |
today | date | Pins “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-days | boolean | Counts 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. | |
variant | inline | card | chart | card | inline: 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-if | fixed | extend | Adds 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. | |
per | day | week | month | day | Period in which rates are written (“$10,756/week”). With working-days, a week is 5 workdays and a month 21.75. |
label | string | Name of the goal, shown in the header and used as the accessible name of the group and in announcements. | |
format | number | currency | number | How amounts are written (Intl.NumberFormat). |
currency | ISO 4217 code | USD | Currency for format="currency". |
unit | string | Suffix for plain numbers (“words”, “km”, “points”); the headline reads “42 words/day”. | |
locale | BCP 47 tag | en-US | Locale for numbers and dates. |
decimals | number | Fixed number of decimals (default: automatic, fewer for large values). Paces are always rounded up at that precision. | |
tolerance | number (days) | 1 | Gap, in days of steady progress, under which you are “On pace” rather than ahead or behind. |
strain | number | 1.5 | When 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-days | number | 14 | Window 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-variant | set by the component | Current 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 |
|---|---|---|
entries | Array<[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 }]. |
holidays | Array<date> | Days skipped in working-days mode (also from <script type="application/json" data-holidays>). |
pace | object | null | Read-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. |
status | string | null | Current tier (read-only). |
whatIfPace | number | null | What-if amount per period. Set it to drive the slider from code; null follows the recent pace again. |
formatter | (value, { signed, rate }) => string | Replaces format / currency / unit / decimals for every amount. |
strings | Partial<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 / … | reflected | Every attribute has a matching camelCase property. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-pace-change | The status tier changed; also fired once after the first computation with previous: null. detail: the pace snapshot plus { previous }. |
mv-whatif | The 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
| Name | Description |
|---|---|
script[data-entries] / script[data-holidays] | Optional JSON children for plain-HTML use; the component’s own DOM is appended after them. |
CSS classes
| Name | Description |
|---|---|
mv-steady-pace-root[data-variant] / -head / -label / -chip[data-tier] | Frame, header, goal label and status chip (icon + words). |
mv-steady-pace-sentence | The one-line sentence of the inline variant. |
mv-steady-pace-need / -need-value / -need-per / -need-text | Headline: the pace needed from today, its period and the sentence under it. |
mv-steady-pace-rail / -rail-fill / -rail-gap / -rail-mark / -gap | Rail: 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 / -projection | Recent pace rows with a meter and a notch at the needed pace, the verdict word, then the projection sentence. |
mv-steady-pace-note | The 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 / -table | Chart parts, markers, legend and the data table disclosure. |
mv-steady-pace-whatif / -modes / -mode / -whatif-input / -whatif-result | What-if group: rule toggle (native radios), native range input (class mv-slider) and result sentence. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-steady-pace-actual | var(--mv-accent) | Actual progress: rail fill, actual line, recent-pace dots, meters. |
--mv-steady-pace-steady | var(--mv-fg-muted) | Steady line color. |
--mv-steady-pace-ahead | var(--mv-success) | Ahead and done tiers, the ahead part of the rail, “Enough” verdicts. |
--mv-steady-pace-behind | var(--mv-warning) | Behind tiers and the behind part of the rail. There is deliberately no red. |
--mv-steady-pace-on-pace | var(--mv-accent) | On-pace chip. |
--mv-steady-pace-whatif | var(--mv-fg) | What-if line, marker and label. |
--mv-steady-pace-chart-height | 13.5rem | Height of the plot area. |
--mv-steady-pace-padding | var(--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.