A few hours a week, compounding. At a goose's pace.
A reusable, self-hosted progress tracker for working through any structured goal with friends or colleagues. Drop in a CSV of items, configure your users and timeline, deploy to AWS, and get a shared dashboard with per-user checkboxes, progress tracking, and a countdown to completion.
Runs on AWS free tier.
- Certification study groups (AWS, CKA, CISSP, etc.)
- Monthly reading lists
- 30-day coding challenges
- Quarterly OKRs
- Any N-period goal with M collaborators
- Any interval β week, month, day, year, sprint, quarter
- N users β defined in config, each with their own login
- Per-user checkboxes β you can only check your own; others are read-only
- Progress bars β items completed + hours completed per user
- Countdown β days remaining to completion date
- Persistent state β stored in DynamoDB, survives page refreshes
- Auth β AWS Cognito email/password login
- Static frontend β no server, just S3 + CloudFront
- Themes β clean default or dark sci-fi LCARS theme, with a βοΈ/π toggle per user
- Local dev mode β iterate on the UI without deploying
- Validate script β verify all AWS resources are healthy
| Default theme | LCARS theme |
|---|---|
![]() |
![]() |
![]() |
![]() |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser β
β index.html + app.js + style.css + cadence.json β
ββββββββββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββββββββ
β β
static assets API calls
β (JWT in header)
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β CloudFront β β API Gateway β
β (HTTPS CDN) β β (HTTP API) β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β
β ββββββββ΄ββββββββ
β β Cognito β
β β JWT Auth β
β ββββββββ¬ββββββββ
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β S3 Bucket β β Lambda β
β (frontend) β β (API logic) β
ββββββββββββββββ ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββ
β DynamoDB β
β (user state) β
ββββββββββββββββ
Data flow:
- The frontend is a static SPA served from S3 via CloudFront.
- On login, Cognito issues a JWT.
- Every API call includes the JWT in the
Authorizationheader. - API Gateway validates it against the Cognito User Pool before the request reaches Lambda.
- Lambda reads/writes checkbox state in DynamoDB, keyed by the user's email extracted from the JWT claims.
Before you start, you need:
| Requirement | Notes |
|---|---|
| AWS account | Create one free |
| AWS CLI v2 | Install guide β run aws configure to set up credentials |
| Python 3.11+ | python3 --version to check |
| Linux or macOS | Windows users: use WSL2 |
Your AWS credentials need sufficient permissions to create DynamoDB tables, Lambda functions, API Gateway APIs, Cognito User Pools, IAM roles, S3 buckets, and CloudFront distributions. An admin-level IAM user works; a scoped policy is better for production.
Estimated AWS cost: negligible. This project uses services well within the free tier:
- DynamoDB: 25GB storage + 200M requests/month free
- Lambda: 1M requests/month free
- S3: 5GB storage + 20k GET requests/month free
- CloudFront: 1TB data transfer + 10M requests/month free (first 12 months)
- Cognito: 50,000 MAUs free
git clone https://github.com/rdyson/cadence.git
cd cadence
python3 -m venv .venv
source .venv/bin/activate # Windows (WSL): same command
pip install pyyaml boto3cp cadence.example.yaml cadence.yaml
cp items.example.csv items.csvOpen cadence.yaml and set:
nameβ your project namecompletion_dateβ your target end date (ISO 8601:YYYY-MM-DD)intervalβweek,month,day, etc.usersβ one entry per person, withid,name, andemailaws.regionβ your preferred AWS region (e.g.eu-west-2,us-east-1)
Open items.csv (or replace it with your own). The build script reads the column names from cadence.yaml β columns, so your CSV just needs a consistent header row.
If you need a break in the schedule, you can define a visible empty period in cadence.yaml and simply leave that period unused in the CSV. This is useful for skip weeks, buffer weeks, holidays, or catch-up periods.
python3 scripts/setup.py
python3 scripts/deploy.pysetup.py deploys all AWS infrastructure via a single CloudFormation stack (~10 minutes on first run):
- DynamoDB, Lambda, API Gateway, Cognito, S3, CloudFront
- OTP auth triggers + SES verification (if
otp: true) - Creates Cognito users from
cadence.yaml
All created resource IDs are written back to cadence.yaml automatically.
deploy.py builds cadence.json from your config + CSV, syncs the frontend to S3, updates Lambda code, and invalidates CloudFront.
Why CloudFront? S3 website URLs are HTTP only. Cognito requires HTTPS. CloudFront provides HTTPS and is free tier eligible.
Each user in cadence.yaml gets a Cognito account with a randomly generated temporary password, printed in the script output:
β Created user: Rob (rob@example.com) β temp password: a8Kz3xQ_mNpR!A1a
β Created user: Adam (adam@example.com) β temp password: bT7wYc2_hLsJ!A1a
On first sign-in, Cognito will prompt each user to set their own password. This is handled automatically by the login screen β they'll see a "Set new password" field appear after their first attempt.
Share the dashboard URL and each user's temporary password with them.
Your CSV needs at minimum a title column and a period column. Hours are optional.
Title,Hours,Week
Introduction to the topic,0.5,1
Deep dive: subtopic A,2.0,1
Deep dive: subtopic B,1.5,2Column names must match the columns settings in cadence.yaml. Defaults are Title, Hours, Week.
Rows are automatically skipped if:
- The title is blank
- The title starts with
--(e.g.-- Foo barcomment rows) - The period value is not a valid integer (e.g. section header rows with no week number)
This means you can use a spreadsheet with section headers and totals β Cadence will ignore them cleanly.
Cadence can show a period even when it has no items, as long as that period is explicitly configured in cadence.yaml.
Example:
completion_date: "2026-05-10"
period_labels:
4: "Week 4 (March 23)"
5: "Week 5 (March 30) Skip"
6: "Week 6 (April 6) Skip"
7: "Week 7 (April 13)"
period_descriptions:
5: "Skip week"
6: "Skip week"Then keep your real items in later numbered periods in items.csv (for example, move old week 5 items to period 7).
This preserves existing checkbox data as long as the item titles themselves stay the same.
See cadence.example.yaml for a fully annotated example.
| Field | Required | Description |
|---|---|---|
name |
β | Project display name |
completion_date |
β | Target end date (YYYY-MM-DD). Move this out if you add skip/buffer periods and want the countdown to match. |
interval |
β | week / month / day / year / sprint / quarter |
csv |
β | Path to your CSV (relative to cadence.yaml) |
columns.title |
β | CSV column name for item titles |
columns.period |
β | CSV column name for period numbers |
columns.hours |
β | CSV column name for time estimates (omit to hide hours) |
users |
β | List of { id, name, email } |
theme |
β | Default theme for new visitors: default or lcars. Users can toggle freely; their choice is saved in localStorage. |
otp |
β | Set to true to enable passwordless email OTP login (see Email OTP login) |
ses_sender_email |
β | SES-verified sender address for OTP emails (required if otp: true) |
period_labels |
β | Override period headings (e.g. 1: "Week 1 β March 2"). Explicitly configured empty periods are still rendered, which enables skip weeks. |
period_descriptions |
β | Optional per-period subtitle text (e.g. 5: "Skip week") |
aws.region |
β | AWS region |
aws.dynamodb_table |
β | DynamoDB table name |
aws.cognito_user_pool_id |
β | Set automatically by setup.py |
aws.cognito_client_id |
β | Set automatically by setup.py |
aws.api_url |
β | Set automatically by setup.py |
aws.s3_bucket |
β | Set automatically by setup.py |
aws.cloudfront_url |
β | Set automatically by setup.py |
CloudFront (HTTPS)
β
βΌ
S3 Bucket
βββ index.html
βββ app.js
βββ style.css
βββ cadence.json β baked from cadence.yaml + items.csv at deploy time
β (JWT in Authorization header)
βΌ
API Gateway (Cognito JWT authorizer)
β
βΌ
Lambda (lambda_function.py)
β
βΌ
DynamoDB
βββ Table: one item per user, map of checked item titles
How auth works:
- Cognito issues a JWT on login.
- The browser includes it in every API request.
- API Gateway validates the token against your Cognito User Pool before the Lambda ever runs.
- The Lambda extracts the username from the validated claims β no auth logic in application code.
| Script | When to run | Description |
|---|---|---|
scripts/setup.py |
Once (first time) | Deploy all AWS infrastructure via CloudFormation |
scripts/deploy.py |
After any changes | Build + upload to S3 + update Lambda + invalidate cache |
scripts/build.py |
After editing CSV/config | Builds frontend/cadence.json |
scripts/validate.py |
Anytime | Checks all AWS resources are healthy |
scripts/dev.py |
During development | Local dev server with mock API (no AWS needed) |
scripts/teardown.py |
To remove everything | Deletes the CloudFormation stack and all resources |
setup.py is safe to re-run β it updates the existing stack if one exists.
Cadence supports passwordless email login as an alternative to passwords. Users enter their email, receive a 6-digit code, and enter it to sign in. This uses Cognito's custom auth flow with SES for email delivery.
- Add to
cadence.yaml:
otp: true
ses_sender_email: noreply@yourdomain.com- Run setup (creates or updates the stack with OTP resources):
python3 scripts/setup.py- Redeploy:
python3 scripts/deploy.pyNew AWS accounts start in SES sandbox mode, which restricts who you can send email to:
| Mode | Sender | Recipients | Daily limit |
|---|---|---|---|
| Sandbox | Must be verified | Must each be verified | 200 emails/day |
| Production | Must be verified | Anyone | 50,000+/day |
For sandbox mode (small teams, testing): setup-otp.sh automatically sends SES verification emails to the sender and all users in cadence.yaml. Each person must click the verification link in their inbox before OTP will work for them.
For production mode (larger teams, no recipient verification): request production access in the SES console or via CLI:
aws sesv2 put-account-details \
--production-access-enabled \
--mail-type TRANSACTIONAL \
--use-case-description "Sending one-time login codes for a private app" \
--website-url "https://your-cadence-url" \
--region your-regionThis is typically approved within 24 hours.
- Add them to
usersincadence.yaml - Run
python3 scripts/setup.py(updates the stack if needed, creates the new Cognito user) - Run
python3 scripts/deploy.py(rebuildscadence.jsonwith the new user column) - Share the dashboard URL + the temporary password from the setup output
Iterate on the frontend without deploying to AWS:
python scripts/dev.pyThis starts a local server at http://localhost:8000 that:
- Serves the frontend from
frontend/ - Mocks the API with a local JSON file (
.dev-state.json) - Auto-builds
cadence.jsonfrom your config - Skips auth β auto-logs in as the first user
- Checkbox state persists across refreshes (stored locally)
Options:
python scripts/dev.py --port 3000 # custom port
python scripts/dev.py --skip-build # don't rebuild cadence.jsonNo AWS credentials, no internet connection required. Edit HTML/CSS/JS, refresh the browser.
Check that all AWS resources exist and are properly configured:
python scripts/validate.pyThis checks: config file, DynamoDB table, Cognito pool + users, Lambda function, API Gateway (including a live 401 test), S3 bucket + files, CloudFront reachability, and IAM role + policies.
Useful for debugging after changes, verifying a fresh setup, or diagnosing "it was working yesterday" issues.
Login fails with "Incorrect username or password"
The user may not have been created. Check that setup.py completed successfully and that the email in cadence.yaml matches what was used to create the Cognito user.
Checkboxes don't save / API errors in console
Check that aws.api_url is set in cadence.yaml (written by setup.py). Rebuild and redeploy: python3 scripts/deploy.py.
Dashboard shows "Error loading cadence.json"
Run python scripts/build.py to generate frontend/cadence.json, then redeploy.
CloudFront returns stale content after deploy
deploy.py creates a CloudFront invalidation automatically. If content still appears stale, wait 1β2 minutes for the invalidation to propagate.
"Access Denied" from S3
The S3 bucket is private by design. Traffic must go through CloudFront. Check that your CloudFront distribution has an Origin Access Control (OAC) set up pointing to the bucket β setup.py handles this automatically via the CloudFormation template.
To remove all AWS resources:
python3 scripts/teardown.pyThis deletes the CloudFormation stack (which removes all resources β DynamoDB, Lambda, API Gateway, Cognito, S3, CloudFront, IAM role) and cleans up the generated values in cadence.yaml. You'll be prompted to type destroy to confirm.
To set up again afterwards, re-run python3 scripts/setup.py as described in Quick Start.
MIT



