All notable changes to this project are documented here.
Sync no longer fights the user. Typing in the Plan-tab textareas (sprint
name / description / retrospective) used to jolt every ~1.5 s — the
background save would call render(), which rebuilds #app via innerHTML,
which destroys the focused textarea and resets the cursor. Two changes
fix it, plus a smaller third one that just cuts API spend.
- Sync pill mutates directly, doesn't trigger a render. New
updateSyncPill()incore.jsfinds (or creates / removes) the.app-sync-pillelement inside the existing header and toggles its text + class.setSyncStatus()calls it on every status change. The rest of the DOM is untouched, so any focused textarea keeps focus and cursor position across the SYNCING… → ok transition. The fullrender()call fromflushSprint/flushEntryis gone in the common path — only fires when something the user can actually see changed (orphan sweep, planning → started transition, or the user is on the Burndown tab where the trends cache invalidation is visible).
-
SPRINT_DEBOUNCE_MSbumped from 1500 → 2500 ms. Prose-style edits (name / description / retrospective) naturally pause longer than checkbox toggles; a 2.5 s window coalesces ~40 % more keystrokes into one PUT at zero UX cost. Entry debounce stays at 1500 ms so check-ins feel instant against the burndown. -
Skip identical PUTs in
flushSprint/flushEntry. Each push caches the JSON body that was last successfully accepted by the server; if the next flush would send the same bytes, it short-circuits before the network call. Catches the case where an edit gets reverted within the debounce window, or where a UI action callspushSprintdefensively without actually changing scoring data.
Fix logical-id casing in the ARecordLegacy override. The deployed logical id
is ARecordE7B57761 (camelCase, as CDK generates it), not ARECORDE7B57761
— the AWS CLI's text rendering had upper-cased the key in my earlier dump.
0.11.7 used the wrong casing so CFN still saw the legacy record as a brand-
new create, hit the same DNS-name collision, and rolled back.
Pin the legacy ght.vexom.io A-record's CFN logical id to its deployed value
(ARECORDE7B57761). The 0.11.6 deploy got far enough to create the apex +
www. ARecords, update the cross-region ExportsReader, and start the
CloudFront distribution update — then failed when CFN tried to create
ARecordLegacyF2E4EB86 for ght.vexom.io while the original ARECORDE7B57761
still owned that name. Same physical resource under a new construct id
read as create-then-delete to CFN, which collides. overrideLogicalId lets
CDK adopt the existing record under the new construct without a recreate.
Also unblocked the deploy by manually patching the deployed main stack via
aws cloudformation update-stack to replace the broken ssm:…:1:1779216106436
dynamic ref with a literal cert ARN — CFN evaluates dynamic refs in the
deployed template during changeset creation, and the old timestamp pin
became unresolvable once the writer rewrote the SSM param. The literal-ARN
detour gave CDK a clean diff to deploy from.
The actual fix for the cross-region writer deadlock. After staring at the
CDK source (cross-region-ssm-writer-handler/index.js), the rule is plain:
no in-place value updates allowed for any existing export key. The writer
diffs OldResourceProperties.exports against ResourceProperties.exports by
key, and any key whose value changed triggers "Some exports have changed!".
This is not drift detection — it's a hard "this key is immutable once
written." The 0.11.4 (delete) and 0.11.5 (re-seed) attempts both ignored
this; the writer doesn't even look at SSM during the check.
Fix: rename the cert construct id from Cert → CertV2. New logical id →
new export key HabitAgilityCertuseast1RefCertV2…. The writer now sees the
diff as "add new key + delete old key" instead of "update existing key" and
proceeds. Main stack picks up the new cross-region reference on next deploy.
Fourth retry — re-seeded the SSM export with the post-rollback cert ARN, assuming the writer compared template-vs-SSM. It does not (see 0.11.6). No application or CDK code changes — failed recovery only.
Third retry of the dual-domain deploy (failed — see 0.11.6 for the actual
fix). Cert stack came back from UPDATE_ROLLBACK_FAILED via
continue-update-rollback --resources-to-skip ExportsWriteruswest209BD44F0A7CF058B,
and the orphan SSM parameter
/cdk/exports/HabitAgility/HabitAgilityCertuseast1RefCert5C9FAEC174FEF386 was
manually deleted from us-west-2. Deletion turned out to be wrong: the CDK
writer doesn't read SSM for its check at all (see 0.11.6). No application
or CDK code changes — recovery only.
Retry of the v0.11.1 dual-domain deploy after the cross-region ExportsWriter rejected the cert ARN replacement. No code changes — re-attempt only after the cert stack rollback was unstuck.
Full rebrand to HabitAgility — completes what v0.10.4 started. AWS infrastructure renamed end-to-end, repo renamed to PascalCase (matches brand), and all documentation rewritten with a product-marketing voice instead of the previous "personal notes" tone.
- README.md — rewritten from scratch as a marketing landing page. Hero pitch ("treat your habits like a Scrum team treats their work"). Streaks-vs- HabitAgility comparison table. Concept glossary (Sprint / Velocity / Granularity / Burndown / PACE / Retrospective / Planning). Tab-by-tab feature overview. Privacy + ownership section. Quick-start, architecture diagram, costs, shipped + roadmap sections. The repo now reads like an invitation to fork and run, not a maintenance log.
- CLAUDE.md — fully aligned to v0.11 infra names; new "Brand vs infra" table is now a single column (all HabitAgility); explicit list of bulk- endpoint / streak / telemetry anti-patterns under "What to avoid".
- CONTRIBUTING.md — sharpened around the deploy-is-CI-only rule, the
cross-region rollback recovery procedure, and the new pure-helpers-in-their-
own-module pattern that lets
tests/import without the AWS SDK.
- CDK stacks renamed:
GoodHabitTrackerCert→HabitAgilityCert,GoodHabitTracker→HabitAgility. The old stacks were deleted viaaws cloudformation delete-stackafter the prior data wipe (no entries or sprint defs to migrate). - DynamoDB tables renamed:
good-habit-tracker-cycles→habit-agility-meta,good-habit-tracker-day-checkins→habit-agility-rows. Construct IDs also renamed for clarity (CyclesTable→MetaTable,CheckinsTable→RowsTable) — the new names match what each table actually holds in the current schema (meta row vs DAY/SPRINT_DEF/SPRINT_SUM rows). - Lambda functions renamed:
good-habit-tracker-sync→habit-agility-sync,good-habit-tracker-auth→habit-agility-auth. - S3 bucket renamed:
good-habit-tracker-app-{account}→habit-agility-app-{account}. - Stack interface renamed:
GoodHabitTrackerStackProps→HabitAgilityStackProps; classGoodHabitTrackerStack→HabitAgilityStack;CertStack→HabitAgilityCertStack. - Lambda env-var names retained (
CYCLES_TABLE_NAME,ENTRIES_TABLE_NAME) to avoid changing the lambda source in this release. The names are now misleading; they'll be renamed toMETA_TABLE_NAME/ROWS_TABLE_NAMEin a follow-up patch alongside theirrequire()consumers inconstants.js.
habitagility.comregistered via Route 53 Domains (~$13/yr, auto-renew on). Hosted zoneZ01841113N4874957M94Cauto-created with NS + SOA records. CloudFront now serves three SANs:habitagility.com(apex, primary),www.habitagility.com(reflex), andght.vexom.io(kept alive so old bookmarks still work). ACM cert validates across both hosted zones viaCertificateValidation.fromDnsMultiZone. Three A-records (apex, www, legacy) alias the same CloudFront distribution.
good-habit-tracker-authLambda@Edge remained after stack delete because edge replicas take hours to clear; it's a no-op orphan and was detached from the deleted stack via--retain-resources. AWS will let it delete cleanly in a few hours.- Old DDB tables and S3 bucket were
RemovalPolicy.RETAIN; they survived the stack delete as empty orphans. Cleaned up out-of-band via the AWS CLI after the new stacks deploy successfully.
StevenEmelander/good-habit-tracker→StevenEmelander/habit-agility(in v0.10.4) →StevenEmelander/HabitAgility(in v0.11, PascalCase matching the brand exactly). Old URLs auto-forward; local git remote updated.
- Root
package.json: namegood-habit-tracker→habit-agility, version bumped to 0.11.0. infrastructure/package.json: namegood-habit-tracker-infrastructure→habit-agility-infrastructure, version bumped to 0.11.0.- Lockfiles regenerated.
Rebrand + Scrum-vocabulary alignment + Plan-tab date compaction.
- App: "Good Habit Tracker" → "HabitAgility". Updated everywhere a user sees it: page
<title>, iOS web-app title meta, header bar, boot-screen caption, "cloud unavailable" caption, doc front matter (README, CLAUDE.md). - "Goal" → "Velocity" in the Plan-tab SCORING section. Matches Scrum terminology — velocity is the sprint's per-day work expectation.
- "Step" → "Granularity" for the per-point increment selector. More explicit than the previous one-syllable label.
- Order flipped in SCORING. Granularity row now appears first (you pick the unit), then Velocity (how many units per day) — natural reading order.
aria-labels updated to match the new labels ("Decrease velocity", "Set granularity to 0.5").
- Date layout: inline label + input on each row (Start and End). The v0.10.3 fix stacked them vertically (input below label) which was correct but tall — each row was ~75 px. Inline gets each row to ~44 px and saves ~50 px total on the sprint card.
- Construct IDs, table names, S3 bucket name, Lambda function names, and CloudFormation stack names are all unchanged. Each of those would be a destroy-and-recreate event under CloudFormation — data loss for the DDB tables, downtime for everything else. The historical
good-habit-tracker-*names work fine and have no user impact. Project rule documented in CLAUDE.md. - Repository renamed on GitHub to
habit-agility(forwards from the old URL; local remote updated). Domain change deferred to a follow-up release once ahabitagility.*domain is registered — see availability check in this release's notes.
- Date pickers no longer overflow on the Plan tab. Previous layout was a
1fr 1frCSS grid that stacked to1frat≤480 pxviewport. The1frdefault isminmax(auto, 1fr), meaning the column can't shrink below the date input's intrinsic content width — so on viewports just above the breakpoint (iPad mini portrait 768 px, iPhone Pro Max landscape, Safari split-screen) the END date overflowed its column even though START fit. Verified at iPhone width with the page rendered in a 388 px simulator: the previous grid showed the END date and its calendar icon spilling past the card; the new flex-column layout fits both inputs cleanly within the boundary. Trades trivial vertical space (one extra row) for zero overflow risk at any viewport.
Plan-tab compaction. The 0.10.1 "restructure" just added section labels; this release actually cuts visible chrome to make the tab usable on a phone without endless scrolling.
- Habit row collapsed to one line. Was
[name] [✎] [YES/NO] [✕]+Points: [−][1][+](~100 px tall per habit, ~132 px of trailing buttons). Now[name] [stepper] [⋯]on a single row — count habits add a small "≤N" stepper on a second line. Per-habit height ~52 px. For a sprint with 9 habits, that's ~430 px of vertical space recovered. Drops the "Points:" / "Limit:" labels (steppers carry the unit in the value:+1,+0.5/u,≤4). - Category card header trimmed. Was
[CATEGORY] [Name] [+ Habit] [Remove](~88 px header per category). Now[CATEGORY] [count badge] [+ Habit] [⋯]— Rename and Remove move into the⋯menu (rare actions, freed up width). The small count badge gives instant visibility into how many habits live there. - Generic action-menu modal replaces the inline per-row triplet of icon buttons. One vertical sheet handles both the habit
⋯(Rename / Switch kind / Delete) and the category⋯(Rename / Delete). The menu items dispatch the existing handlers — no business-logic changes. - Sprint card: dropped the duplicate "14 days · planning" line. The dates already imply the count and the planning hint card already says "planning". Saves ~22 px.
- "+ Category" toolbar card removed. Was a whole card just to hold a label and one button (~70 px). Now lives at the end of the category list as a subtle full-width button — present but unobtrusive when categories exist. In the empty state it's the CTA inside the empty-state card.
- "Plan" tiny caption at the top removed. The bottom-tab label already says PLAN. ~22 px reclaimed.
- Single-line planning hint ("📋 Planning — start date locks on your first entry.") and single-line warning ("⚠ Editing past day 1 may change today's tallied score. Use Next instead."). Verbose three-line prose belongs in docs, not in the user's daily viewport — ~40 px each.
For a typical view (current sprint, 3 categories × 3 habits, planning state):
| Item | Before | After | Saved |
|---|---|---|---|
| "Plan" caption | 22 | 0 | 22 |
| Planning hint | 80 | 36 | 44 |
| Sprint length line | 22 | 0 | 22 |
+ Category toolbar |
70 | 0 | 70 |
| 3 × category headers | 264 | 132 | 132 |
| 9 × habit rows | 900 | 470 | 430 |
| Total | ~1358 | ~638 | ~720 px |
A 14-inch desktop scroll became a phone-screen glance.
- Dropped now-dead CSS rules:
.plan-h,.plan-cat-toolbar*,.plan-btns*,.plan-habit-top,.plan-kind,.plan-scores,.plan-score-group*,.plan-goal-headline*(had cleaned the markup but rule was lingering). - Dropped the
habitKindLabel()function (no longer rendered anywhere — the kind toggle was inline; switch-kind now lives in the⋯menu). state.actionMenushape mirrorsstate.textModalfor symmetry — both modals close on backdrop click + Cancel button + render incore.jsrender()alongside the existing add-habit modal.
Plan-tab re-evaluation + testing infrastructure. No infra changes — pure front-end + lambda-side refactor for testability.
- Sprint card restructured into labeled sections. Top-of-card caption (CURRENT SPRINT / UPCOMING SPRINT) then a meta block (name + description), then a SCHEDULE section (dates + length), then the existing SCORING section (goal + step). Each new section gets a thin border-top + a small mono caption — reads as three logical clusters instead of one tall stack of inputs.
- Removed the duplicate goal/day headline. The big "10 goal/day" number that appeared above the date row was a redundant display of the same value the Goal stepper already shows. The stepper is the editable surface; the headline was just visual noise.
- Empty-state card when the focused sprint has no categories. Replaces the previously-empty gap below the "+ Category" toolbar. Includes example category names for first-time users.
- Habit kind labels:
Y/N→YES/NO,CNT→COUNT. The 3-character codes were opaque on first encounter; the longer labels are still short enough to fit in the button row.
- New generic
renderTextModal()+state.textModalshape inplan-ui.js. Used by add-category, rename-category, and rename-habit instead of the iOS-ugly nativeprompt()dialogs. - Auto-focus + auto-select of the pre-filled value on open (matching the existing add-habit modal pattern).
- Enter submits, Escape cancels via a new keydown delegate on
document.body. Matches whatprompt()gave for free; iOS Safari's keyboard Return key now submits the modal. - Backdrop click cancels. Modal alert content shielded from event bubbling so the cancel only fires when clicking outside the alert box.
- Extracted pure helpers from
infrastructure/lambdas/sync/sprints.jsinto a newsprint-helpers.jsmodule.findCovering,safeLengthDays,safeGoalPoints,safePointStep,sprintItemToObject,sprintObjectToItemall live there now and import only fromconstants.js+utils.js(no@aws-sdk/*chain) — so they're importable from the roottests/without requiring the SDK at the test runner's resolution scope.sprints.jsre-exports them so all existing callers (entries.js,summaries.js) work unchanged. tests/lambda-utils.test.js— 34 new tests coveringaddDays(positive/negative/boundaries),daysBetweenInclusive,clampToToday,quantize(float drift, type coercion),clampText,safeJsonParseObject(incl. non-object inputs),isValidDateKey,isValidSprintId,parseSprintIdParam,todayKey.tests/lambda-sprint-helpers.test.js— 29 new tests covering the threesafe*validators (edge cases, type coercion, range clamping),sprintObjectToItem↔sprintItemToObjectround-trips for both started and planning sprints, andfindCovering(no sprints, started-only, planning fallback, multiple planning, sparse arrays).- Tests caught real code looseness in three helpers:
safeLengthDays(null)was returning1(becauseNumber(null) === 0is finite and clamps to 1) instead of falling back to the default. Added explicitnull/undefined/''guard at the top.safeGoalPoints(null)had the same bug — returning0instead of10. Same guard.safeJsonParseObject('[]')was returning[]becausetypeof [] === 'object'. Tightened to also reject arrays — callers always expect a plain object and would have surfacedbody.categories === undefinedotherwise.
- 101 tests pass (was 38).
- Plan-tab warning + planning-hint cards moved from inline
stylestrings to.plan-warningand.plan-hintCSS classes. - "CURRENT SPRINT" caption + meta-block wrapper moved to
.plan-sprint-head+.plan-meta. - Loading-state muted text moved to
.plan-loading. findCovering,sprint*shape helpers, and thesafe*validators are now insprint-helpers.jswith comments documenting the test-importability design.
UX polish + UI rename pass driven by walking each tab on a phone. No API
or data-model changes — UI-only renames keep the internal trends* naming
intact for state, file paths, and the /api/trend/* route family.
- Tab
TRENDS→BURNDOWN. Reflects the centerpiece — the Agile burndown chart is what the tab is for. Internalstate.tab === 'trends'anddata-tab="trends"unchanged. - Mode buttons
SPRINT OVERVIEW→THIS SPRINTandALL-TIME→ALL SPRINTS. Less jargon, clearer scope of what each view covers. - All-Time header card title
ALL-TIME→ALL SPRINTS(matches the new mode-button label).
- Entry header redesigned. Sprint name promoted to a prominent line (16 px / 700 weight, truncates with ellipsis) paired with an inline
DAY k / Nchip — orPLANNINGchip on a planning sprint. Day-in-sprint is computed for the viewed date, not just current, so navigating to past days shows the correct day-of-sprint. Day-nav row tightened — arrows are 44×44 withflex-shrink: 0, date line centers between them with ellipsis on overflow. - Boolean habit toggle: filled background when on. Uses the category accent at 18 % alpha (via
color-mix) for the on-state background and border, plus the label goes 600-weight. Off vs on is now distinguishable from across the room — not just from the●vs○glyph and color shift. - Global header bar stacks on narrow phones. Below 420 px viewport width, the title moves to its own line and the status + sync pill drop below it. iPhone SE no longer cramps the "Good Habit Tracker" + "DAY 1/14" + status combo.
- Plan tab: Goal stepper + point-step selector visually grouped as
SCORING. Border-top, small caption, indented rows — reads as one settings cluster instead of two adjacent button rows that look identical. - Burndown chart: x-axis tick labels. Three monospace tick labels at chart bottom (
d1/d{mid}/d{N}) orient the timeline on narrow phones. Chart height bumped from 120 to 130 px to fit the label row without crowding the data. - Pace metric prefixed with
↑/↓/·glyph. Reinforces the color-coded sign for users who can't easily distinguish accent (gold, ahead) from danger (terracotta, behind) at a glance. - Boot screen and
SYNCING…/SYNC FAILEDpills moved off inline styles into named CSS classes (.boot-card,.boot-headline,.app-sync-pill,.app-sync-error) — semantic markup, plus less DOM-string noise.
- Repeated inline
style="..."patterns moved to CSS classes wherever I touched a file — new class families:.app-header*,.boot-*,.entry-*,.plan-scoring*,.plan-goal-headline*,.plan-length-line,.trends-mode-switch,.trends-alltime-*. Render templates are noticeably easier to read; visual tweaks now happen in CSS rather than scattered across${...}interpolations. - Dead CSS rules removed.
.title(replaced by.app-title) and.plan-sprint-dates(removed from JS several releases ago). dayInSprint(sprint, dateKey)helper inentry-ui.js. Replaces an ad-hocsprintInfo()reach-through for "what day of the sprint is this viewed date?" — the entry-header day chip needs per-viewed-day computation, not "current sprint, today" semantics.- All buttons that lack visible text labels (counter
−/+, score-edit−/+, point-step buttons) gained descriptivearia-labels.
CLAUDE.mdandREADME.mdupdated for the rename. CLAUDE.md notes explicitly that internaltrendsMode/trendsSprintId/trends-ui.js//api/trend/*stay as-is — only user-visible strings changed.
A coordinated hardening release driven by five specialist agents (PM, security
engineer, senior dev, UX designer, systems engineer). No new user features;
focus is on security, accessibility, cost, observability, and DR posture. The
DynamoDB tables were also wiped to start fresh (backups/ddb-rows-20260519-122914.json
and ddb-meta-20260519-122914.json are the immediately-pre-wipe snapshots).
- CloudFront
ResponseHeadersPolicyon the default behavior. AddsStrict-Transport-Security: max-age=63072000; includeSubDomains; preload, a tightContent-Security-Policy(default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer. The/api/*behavior uses the lighter AWS-managedSECURITY_HEADERSpolicy. - CloudFront API origin:
QueryStringBehavior.none()(was.all()). No/api/*route reads query strings, so dropping them shrinks attack surface — anunlock=param can't accidentally reach the API origin if edge auth is ever bypassed. - 64 KiB request-body cap in
getBody. Returns 413 before any handler sees an oversized payload; previously the 6 MB Function URL ceiling was the only limit. - Generic 500 error responses. The catch-all in
index.jsno longer echoes SDK error messages (which could leak table names / ARNs / AWS error codes). Errors areconsole.error-logged server-side; the response body is{ "error": "internal" }. - Server-side validation of sprint numeric fields. New
safeLengthDays,safeGoalPoints,safePointStephelpers insprints.jsclamp/validatelengthDays(1..365 integer),goalPoints(0..10000 finite), andpointStep(must be one of[0.1, 0.25, 0.5, 1]). Defends againstNaN/non-numeric values from a buggy or hostile client. Array.isArraychecks onbody.categoriesandbody.habitDefinitionsin both POST and PUT handlers — previouslybody.categories || []would have happily accepted a non-array value.escapeHtml(id)on everydata-idattribute inplan-ui.jsandentry-ui.js. IDs come fromuid()today so this is hygiene, but the layered defense protects against a future direct-API write of a crafted id.
The security engineer's findings on the auth Lambda — timing-safe cookie
compare (crypto.timingSafeEqual), htok Max-Age dropped to 30 days,
safeRedirectPath open-redirect guard, nosniff/X-Frame-Options/
Referrer-Policy on the 403 response — were implemented but reverted before
shipping because any change to the Lambda@Edge code triggers the CDK
cross-region SSM export deadlock (ExportsWriteruswest209BD44F0A7CF058B rejects
the update because the main stack still imports the old version ARN). Shipping
them safely requires either the documented 3-step temp_drop_edge_auth deploy
(removed in v0.7 because the user disliked the auth gap) OR a re-architecture
of the cross-region reference. Tracking as v0.10 work — the CloudFront default
HSTS+CSP headers already cover the most important client-side guarantees.
logRetention: ONE_MONTHon the sync Lambda (was infinite — CloudWatch storage was accruing forever).memorySize: 256on the sync Lambda (was the 128 MB default). CPU scales linearly with memory; ~halves p99 latency for ~1.5× the per-ms cost, net cheaper on this workload because requests finish sooner. The auth Lambda@Edge stays at 128 MB — the function is too trivial to benefit.
- DynamoDB point-in-time recovery enabled on both
CyclesTableandCheckinsTable. ~$0.20/GB-month at this scale (pennies). Protects against bad client writes / orphan-sweep regressions thatRemovalPolicy.RETAINdoes not. - S3 versioning enabled on
AppBucketwith a 30-day noncurrent-version expiration. Undoes accidentalBucketDeploymentoverwrites; storage cost is negligible. scripts/backup.ps1andscripts/backup.shrewritten against the current per-item REST API. Previous versions hit removed/api/cyclesand/api/entriesendpoints — every run had been failing silently since the v0.5 refactor. New scripts walk/api/trend/sprint-summary, fetch each/api/sprint/:id, then enumerate every covered date calling/api/entry/:dateKey. Output: timestamped JSON with{ sprints, entries }.
- Touch targets ≥ 44×44 px (Apple HIG + WCAG 2.5.5).
.btnand.tabnow havemin-height: 44pxandpadding: 10px 14px; counter columns widened from 36 px to 48 px. Significantly reduces mis-taps on the most-used Entry-tab interactions. - Bottom-tabs visual separation. Stronger border-top, backdrop blur (8 px) where supported, and a subtle drop shadow. The toolbar now reads as a fixed shelf rather than fading into the list.
PLANNINGhint card on the Plan tab when the current sprint hasn't started yet. iOS has no hover, so the existingtitle=tooltip on the disabled start-date input was invisible. The hint explains what's happening and where to go to start.--mutedcontrast bumped from#7a7a85to#9a9aa5. Old value was 4.4:1 on the dark theme — just barely WCAG AA and uncomfortable at 10–12 px small-text. ~6.4:1 now.:focus-visibleoutline on.btnand.tab. Keyboard users can now see where they are; previously only.plan-inputhad a focus style.aria-label="Main"+role="tab"+aria-selectedon the bottom tabs. Screen readers can now announce which tab is current.aria-pressed+ descriptivearia-labelon boolean habit toggles. Plusaria-live="polite"on the count display so increments are announced.aria-labelon counter +/− buttons including the habit name.<label>wrapping the retrospective textarea so the caption and input are properly associated.
- "Planning" sprint state. A sprint created when no other sprint exists (typically the very first one, also any first-sprint-after-a-gap-day via
ensureCurrentSprint) is born withstartDate = nullandendDate = null. The Plan tab renders its start input as today's date (disabled), its end input astoday + lengthDays − 1(editable — adjusts duration), and tags the day-count line with· planning. The lambda'sfindCoveringfalls back to the lowest-ID planning sprint when no started sprint covers the queried date, so the Entry tab and entry GETs work seamlessly while planning. - First-entry transitions the sprint. When the lambda's
handlePutEntrystamps the first entry against a planning sprint, it setsstartDate = entry.dateKey,endDate = startDate + lengthDays − 1, and returns the new dates in{ sprintStarted: { sprintId, startDate, endDate } }. The client patches local state on receipt so the UI flips from "PLANNING" to "DAY 1 / N" without a full reload. - Date pickers are locked after start. Both start and end inputs on the Plan tab are
disabledonce a sprint has a realstartDate. This trades the escape-hatch for cleaner semantics — sprint dates are immutable once you've actually started doing the work. (lengthDaysis implicitly locked too, since the buttons that adjusted it were removed in this release.) isSprintInPlanning(sprint)helper inscoring.js, re-exported viacore.js. Used by Plan UI, Trends UI, and the date-change handler.
±14dlength stepper buttons on the Plan tab. Redundant with the start/end date pickers — adjust via dates. Length still displays below the date row.
- New sprints always default to 14 days regardless of the prior sprint's length.
pointStepandgoalPointsstill inherit (they're scoring settings the user has tuned), but length doesn't — the date pickers are the right surface for adjusting a specific sprint's window. Affects both first-sprint creation (ensureCurrentSprint) and next-sprint creation (Plan tab → Next). - Trends Sprint Overview gracefully handles planning sprints. Header shows "Not started yet · N days planned" instead of
null → null. Metrics and the burndown chart are replaced by a single message pointing to the Entries tab. Retrospective stays hidden (lambda + UI both gate oncanEditRetrospective, which is false for planning sprints). - Header bar reads
PLANNINGinstead ofDAY N/Mwhen the current sprint hasn't started yet. - iOS auto-zoom fix. Sprint name/description inputs, date pickers, and the retrospective textarea bumped to
font-size: 16px. Safari only auto-zooms on focus when the input font-size is below 16, so this disables the unwanted zoom without touching viewportuser-scalable(which would block accessibility pinch-zoom).
- Burndown chart in Trends → Sprint Overview. Replaces the daily-points line chart with an Agile-style burndown: a dashed ideal line from
(day 0, totalGoal)to(day N, 0), and a solid actual line that trackstotalGoal − cumulative earnedper day. Sitting below the ideal means you're ahead of pace. - PACE metric alongside POINTS in Sprint Overview. Shows
±N(color-coded — ahead in accent, behind in danger, on-pace in muted) plusday X / Yfor at-a-glance progress. - Sprint date pickers in Plan tab. Native
<input type="date">for both start and end. End date clamps to start; length recalculates on commit;changeevent re-renders (vs theinputno-render path used for free-text fields). Future-proofs date editing beyond the ±N stepper. Pickers stack to a single column below 480 px so iPhone widths don't overflow. LIVEalias on the Lambda@Edge auth function. Stable handle for monitoring and manual invocation. CloudFront still references the version ARN (Lambda@Edge rejects alias ARNs)..claude/settings.json+PreToolUsehook (block-local-deploy.js) that block localcdk deploy,deploy.ps1, anddeploy.shinvocation — including substring-matched wrapped variants likePush-Location infrastructure; npx cdk deploy .... Forces deploys through GitHub Actions.
- Trends Sprint Overview header. Sprint name (or
Sprint Nfallback) now sits inline between the prev/next arrows instead of a separate row below the mutedSPRINT Ncaption. Cleaner, less vertical space, no duplicate identifier. - Empty description/retrospective hide entirely instead of showing italic "No description." placeholder text. Past + current sprints with no retro still show the editable input so the user can add one; upcoming sprints hide the retro block completely.
- POINTS metric reformats to
X / total(instead ofXplus a separate "of total" subtext) withN leftunderneath. Less wrapping on narrow phones. PACE subtext font is smaller for the same reason. - Length stepper is now ±14d (was ±7d), aligning with the default sprint length. Minimum floor for the stepper is 1 day (date pickers can go anywhere).
temp_drop_edge_authCDK context. The two-phase deploy workaround is no longer the documented path. Recovery from the rareExportsWriterrollback-stuck state is now:aws cloudformation continue-update-rollback --resources-to-skip ExportsWriteruswest209BD44F0A7CF058B --stack-name GoodHabitTrackerCert --region us-east-1, then re-runcdk deploy --allvia CI.
- Sprint name + description + retrospective. Every sprint now carries optional
name(≤80 chars),description(≤2000), andretrospective(≤5000) fields. Name and description are edited in the Plan tab sprint card; retrospective is edited in the new Trends → Sprint Overview view. Each is backward compatible — existing sprints read as empty strings until edited. - Sprint name in Entry header. When a sprint has a name, it renders above the date line on the Entry tab — gives daily context (e.g. "Hibernation Recovery") without taking real estate when unset.
canEditRetrospective(sprint, todayKey)+clampSprintText(value, max)helpers inapp/scripts/scoring.js. Pure, testable. Lambda mirrors viaclampTextinutils.js.- Tests. 12 new test cases covering
canEditRetrospective(past / current / first-day / upcoming / null) andclampSprintText(trim, slice, coerce, empty).
- Trends redesigned: two modes only.
SPRINT OVERVIEW(default) walks every sprint with prev/next — name, description, daily-points chart with goal line, summary stats, and editable retrospective.ALL-TIMEplots one point per sprint at avg pts/day across the user's whole history, with a per-sprint legend. The four-mode switcher (sprint / month / year / all) is gone. - Sprint summary row gains
name. Powers the All-Time chart's sprint labels without a per-sprint round trip. Invalidated when a sprint's name changes (handlePutSprintsummary-invalidation gap caught in plan review and fixed). - Text-edit focus preservation. Sprint name/description/retrospective edits flow through a dedicated
inputevent listener that updates state and debounces save without re-rendering. Going through the click pipeline would have rebuilt the DOM and dropped focus + cursor position on every keystroke. - Retrospective gating (defense in depth). UI disables the retro textarea on upcoming sprints; lambda also rejects retrospective edits with 400 when
body.startDate > today.
- Trends month-mode endpoint (
GET /api/trend/month/:yyyy-mm) and its handler. - Trends month + year modes in the UI.
state.trendsMonthandstate.trendsYearremoved. isValidYyyymmhelper in lambda utils (unused after month route removal).todayYearMonth/todayYear/offsetMonthhelpers in front-end handlers (dead code without month/year modes).
GoalreplacesMaxas the headline ceiling concept. New per-sprintgoalPointsfield (daily, default10); UI showspts / goal; trends chart has a dashed goal reference line; the bounded "max possible" math is gone from the user-facing UI.- Unlimited count habits. Setting a count habit's daily limit to
0makes it open-ended — counter has no upper clamp; UI rendersn(notn / limit);Limit: ∞in Plan. - Renamed
Cycle→Sprintthroughout: API routes (/api/sprint/*,/api/trend/sprint/*,/api/trend/sprint-summary), DDB partition keys (main#SPRINT_DEF,main#SPRINT_SUM), the entry-row attribute (sprintId), UI labels, CSS class names, and every code symbol. The meta-row'snextCycleIdbecomesnextSprintId. Aligns terminology with the Agile sprint model. - Tests. Vitest setup at the repo root with parity tests for
pointsForEntry(lambda ↔ front-end),quantize,fmtPoints,fmtPointsForStep,decimalsForStep,pointStep,goalForSprint. 24 tests passing. - Linter + formatter. Biome at the repo root:
npm run check,npm run check:fix. Auto-formatted the entire codebase to a single consistent style. - GitHub Actions CI.
.github/workflows/ci.ymlruns Biome + Vitest +cdk synthon every PR..github/workflows/deploy.ymlruns the gate +cdk deployon tag pushes (X.Y/X.Y.Z) and on manual dispatch. - JSDoc types for Sprint, Entry, Habit, Category, Summary, DayBucket in
app/scripts/types.js— IDE autocomplete on the shared shapes without adding TypeScript.
- Lambda split into modules. The 700-line single-file lambda is now 10 cohesive modules:
index.js(router + dispatch),constants.js,utils.js,db.js,scoring.js,meta.js,sprints.js,entries.js,summaries.js,orphan-sweep.js. Behavior is unchanged. - Front-end constants centralized in
app/scripts/constants.js(debounces, default goal, default sprint length, default point step, API base paths, chart caps). The math is inapp/scripts/scoring.js(pure, no state, no DOM);core.jsre-exports both so existing import sites keep working. - handlers.js refactored to an action map. Replaced the 26-branch
if (action === '...')chain withpreBootActions/globalActions/entryActions/trendsActions/planActionslookup tables. Each handler is a small function receiving{ event, target, action, id, delta }. bumpBoundsOnPutcollapses 3 DDB UpdateItems into 1 read-modify-write. Caller can pass a pre-fetched meta row to skip the extra read entirely. ~67% fewer write ops on the entry-edit hot path.- Multi-user-ready namespace kept in place: every DDB key is prefixed via
userKey(); sprint defs are individual rows underpk='main#SPRINT_DEF'; sprint summaries underpk='main#SPRINT_SUM'; entry rows carrysprintIdfor one-round-trip GETs.
- All migration scaffolding (the cycle→sprint migration ran once on the first deploy, then was stripped in the next deploy). No legacy attribute fallbacks anywhere; no
ensureMigratedplumbing. - The "max points" concept in the UI. The progress bar and trends charts now key off
goalPointsinstead oftotalMax.habitMax,categoryMax,totalMaxremoved from core.js. The cycle-summary'smaxattribute is gone (re-deriveable fromgoalPoints × days).
- DDB partition
main#CYCLE_DEF→main#SPRINT_DEF(rows rewritten in place). - DDB partition
main#CYCLE_SUM→main#SPRINT_SUM(old summaries dropped, lazy-fill on next trends view). - Entry-row attribute
cycleId→sprintId. - Meta-row attribute
nextCycleId→nextSprintId.
- Configurable point granularity per cycle (
cycle.pointStep:0.1,0.25,0.5, or1). The +/- buttons in Plan use this step forpointsandpointsPerUnit;maxUnitsstays integer. Switching the step snaps every existing habit value onto the new grid (e.g., 0.25 → 0.5 turns 1.25 into 1.5). New cycles inheritpointStepfrom the cycle they're cloned from. - Step-aware display precision throughout the app:
1/1for step1,1.0/1.0for step0.5or0.1,1.00/1.00for step0.25. Each entry uses its own cycle's step, so old days render in their original precision. - Plan-tab edit safety: opening Plan past day 1 of the current cycle auto-selects Next. Toggling back to Current shows a red warning banner — edits past day 1 can change scores already tallied.
- Count-habit clarity: the counter row now shows
n / maxUnits(units progress), separate from the points conversion in the header. - Mode-specific trends endpoints (one round-trip each):
GET /api/trend/cycle/:id— daily buckets within one cycle.GET /api/trend/month/:yyyy-mm— daily buckets for a month.GET /api/trend/cycle-summary— one aggregate per cycle (year + all-time views share this).
- Cycle-summary storage at
pk='main#CYCLE_SUM', lazy-filled on first read and invalidated on entry/cycle writes. All-time trends become O(1) DynamoDB Query after first view. POST /api/cyclewith server-assigned integer ids (atomicnextCycleIdincrement on the meta row). Front-end never picks an id.userId-prefixed partition keys on every DynamoDB row (main#DAY,main#CYCLE_DEF,main#CYCLE_SUM). Multi-user is now a one-line change — replace theUSER_IDconstant with a per-request lookup.
- Strict per-day entry loading. Boot fetches only
GET /api/entry/:todayplusGET /api/cycle/:id. Day navigation loads exactly one entry. No more bulk-load on app start. - Cycle ids are positive integers (1, 2, 3, …). Trends prev/next cycle is
id ± 1. UUIDs from prior versions are migrated in place. - Cycles split into per-row items (
pk='main#CYCLE_DEF', sk=cycleId) instead of onecyclesJsonblob. PUT cycle is O(1) regardless of total cycle count and no longer bound by DynamoDB's 400 KB item limit. - Entry rows carry
cycleId, stamped at write time. Entry GET is one round-trip; re-stamped on cycle PUT when the date range moves. - Orphan-habit sweep is conditional — only runs when habit ids are genuinely orphaned (removed from this cycle and not present in any other). Bounded to the union of cycle ranges.
- Parallel cycle-summary fill via
Promise.allover missing cycles. - Bounds bump consolidates to one round-trip (initial
if_not_exists+ two parallel conditional extends) instead of three sequential UpdateItems. - Trends UI driven by mode-specific data sources: cycle/month modes plot daily buckets; year/all-time plot one point per cycle at its
startDate(cycle averages). - Front-end
state.cycles[]removed, replaced with sparsestate.cyclesByIdmap. The full cycle list is never held in memory.
- Bulk endpoints:
GET /api/cycles,GET /api/entries. - DELETE endpoints:
DELETE /api/cycle/:id,DELETE /api/entry/:date. PUT entry with emptyhabitValuesByIddeletes server-side; nothing in the UI deletes a cycle. cyclesJsonblob on the meta row. Cycles are now individual rows.- Date-range query parameters on entry endpoints — strict per-item access only.
- Cycle UUIDs → integer ids (sorted by
startDate). cyclesJsonblob → individualCYCLE_DEFrows.- Plain
pk='DAY'entry rows →pk='main#DAY'withcycleIdstamped from the covering cycle. - Plain
pk='CYCLE'summary rows →pk='main#CYCLE_SUM'.
- Per-item REST API under
/api/*:GET/PUT/DELETE /api/cycles/:id,GET/PUT/DELETE /api/entries/:date, plusGET /api/cyclesandGET /api/entriesfor boot. All reads are partition-targeted Query / GetItem (no Scans, no date-range parameters). - Per-item debounced writes in the front-end:
pushCycle(id)andpushEntry(date)keyed by item, replacing the previous "send the whole world on every edit" path. Toggling a checkbox now produces exactly onePUT /api/entries/:dateand no cycles traffic. - Server-side orphan-habit sweep: when a cycle is updated or removed, the lambda strips habit ids that are no longer defined by any cycle from every entry row and returns
removedHabitIdsso the front-end mirrors the sweep locally. - Backup script at
scripts/backup.ps1(andbackup.sh): hits the new endpoints with thehtokcookie and writes a single timestamped JSON file tobackups/.
- Naming consistency throughout. Renamed
todayUI references toentry/entriesandtunereferences toplan. File renamestoday-ui.js→entry-ui.js,tune-ui.js→plan-ui.js. Function renamesrenderToday→renderEntry,renderTune→renderPlan. State renamesstate.checkinsByDate→state.entriesByDate,state.checkinBounds→state.entryBounds,state.tuneMode→state.planMode. CSS classes.tune-*→.plan-*. Wire fieldcheckinBounds→entryBounds. DynamoDB attributescheckinDateMin/Max→entryDateMin/Max(existing data migrated in place). Tab labelTUNE→PLAN. - Storage layout is unchanged at the table level (same names, same partition keys); the lambda now exposes per-item endpoints over the existing rows. Bounds are maintained incrementally on every entry put/delete instead of by scanning the entire entries table on every write.
- Boot is two parallel calls (
GET /api/cycles+GET /api/entries) instead of a single 730-day range fetch.
- Legacy
/api/syncroute. GET (?from=&to=) and POST (partial: true/deletedCheckinDates[]) are gone; cutover was atomic. - Legacy front-end paths:
schedulePush,purgeOrphanHabitData(now server-side),_loadedRange,ensureDayLoadedThenRender,ensureTrendsRangeLoaded,fetchCheckinsRange,rangeFullyLoaded,stripLegacyRestFromCheckins. The orphan-isRestDaycleanup is no longer needed. - Legacy DynamoDB attribute
_lastModifiedon the cycles row.
- Modular app: Styles and scripts split into
app/styles/andapp/scripts/while keeping a single deployableapp/tracker.htmlshell. - Trends: Day, week, month, and year summaries; dual charts and a 30-day view from loaded cloud data (no local backup/export UI).
- Tune & copy: Tune UX polish; clearer entries wording where it replaces older labels.
- Sync & storage: Replaced the single DynamoDB blob with
good-habit-tracker-cycles(one item:cyclesJSON +_lastModified+ check-in date bounds) andgood-habit-tracker-day-checkins(pk = DAY,dateKeysort key) for efficient Query by date range. - API:
GET /api/sync?from=YYYY-MM-DD&to=YYYY-MM-DDreturns check-ins in range pluscheckinBounds.POSTsupportspartial: truewith only changed days anddeletedCheckinDates; full replace whenpartialis false (replaces all remote day rows from the payload). - App: Partial cloud saves for edited days; cycle logic and rendering aligned with multi-cycle habits.
- Infra: CloudFront forwards query strings to the sync origin; CDK
S3BucketOrigin.withOriginAccessIdentityreplaces deprecatedS3Origin. Deploy scripts and stack wiring updated for the sync path.
- Legacy DynamoDB tables
good-habit-tracker-stateandhabit-tracker-state(superseded single-table designs). If you upgraded from an older stack, delete any retained empty tables in us-west-2 that match those names.