Transform raw subscription transaction data into predictive retention intelligence — churn risk scores, CLV projections, cohort analysis, revenue forecasts, and AI-generated executive summaries.
Most analytics tools stop at reporting — they tell you what happened. This platform goes further, answering the questions that drive business action:
"Which customers are about to leave, how much future revenue is at risk, why are they leaving, and what should we do about it?"
It is a full-stack analytics and machine learning platform built as a public portfolio project. Every line of code follows production engineering standards — typed, tested, linted, and containerised — to demonstrate what a Senior Analytics Engineer builds when sitting down to solve a real retention problem.
This section explains each completed phase in plain language so anyone reviewing this portfolio can understand what problem was solved and what skill it demonstrates.
What it is: Setting up the "building" before the furniture goes in.
Before writing a single line of analysis code, a professional engineer establishes the infrastructure that makes a project maintainable, reproducible, and safe to share publicly. This phase created:
- A structured folder layout so every module has a logical home
- A configuration system that reads settings from a file and environment variables — no hard-coded values anywhere
- A structured logging system that records what the application is doing without ever logging personal customer data
- A CI/CD pipeline (automated checks that run on every code change via GitHub Actions) covering code style, type safety, and security scanning
- A Dockerfile so the application runs identically on any machine or cloud environment
Why it matters: A project without this foundation becomes brittle, hard to hand off, and dangerous to run in production. Building it first is the professional approach.
What it is: Teaching the platform to safely accept and understand raw data files.
A business uploads a CSV or Excel file of transaction records. This phase built everything that happens between "file dropped" and "data ready for analysis":
- File validation — rejects unsupported formats, enforces a size limit, and detects potential security risks in uploaded files (CSV formula injection)
- Schema normalisation — intelligently maps common column name variants (
revenue,amount,order_date,user_id) to the internal standard so users don't need to rename their files - Date format detection — automatically detects whether dates are American (MM/DD/YYYY), European (DD/MM/YYYY), or ISO format, and asks the user to confirm when it's ambiguous
- Data quality checks — identifies null values, future dates, negative amounts, and duplicate records, each flagged with a severity level (error, warning, or info)
- Sample data generator — creates a realistic 2,000-customer synthetic dataset for demo purposes, with a built-in churn lifecycle that produces credible model AUC scores
Why it matters: Garbage in, garbage out. A machine learning model trained on bad data produces wrong predictions. This layer ensures analysts can trust what flows into the system.
What it is: Computing the core business health metrics from transaction history.
Once data is validated, this phase calculates the key indicators a subscription business uses to measure its health:
- RFM Features — For each customer: Recency (how long since they last paid), Frequency (how many times they've paid), and Monetary value (how much they've spent in total). These three numbers are the foundation of almost every customer scoring model in the industry.
- Churn Labels — Determines which customers have "churned" (stopped paying) based purely on gaps in their transaction history. Crucially, the platform never uses a "subscription_status" column as a shortcut — doing so would make the model trivially accurate in testing but useless in production, a common mistake called label leakage. A
LABEL_LEAKAGE_COLUMNSblocklist and automated test enforce this permanently. - Cohort Retention Matrix — Groups customers by the month they first subscribed, then tracks what percentage of each group was still paying in month 1, month 2, month 3, and so on. This is the standard way subscription businesses measure product stickiness.
- KPI Time Series — Month-by-month trends for MRR (Monthly Recurring Revenue), active subscriber count, ARPU (Average Revenue Per User), and monthly churn rate.
- Analytics Dashboard Page — All of the above rendered as interactive Plotly charts in the Streamlit app, with a colour-coded cohort heatmap.
Why it matters: These metrics are the vocabulary of subscription businesses. Every VP of Product, CFO, and Growth team lead speaks in these terms. An analyst who can build this pipeline from raw transactions is immediately valuable.
What it is: Training a model that predicts which specific customers are likely to leave.
This phase moves from "describing what happened" to "predicting what will happen":
- Temporal Train/Test Split — A custom
TimeSeriesChurnSplitclass splits the customer population by when they first joined: older customers train the model, newer customers test it. This mirrors real-world deployment — you train on historical data and score new customers. A standard random split would be incorrect here because newer customers could accidentally inform the model's training, a form of data leakage. - Churn Prediction Model — An ensemble of two algorithms: Logistic Regression (interpretable, well-calibrated probabilities) and Random Forest (captures non-linear patterns). Both use
class_weight='balanced'to handle the typical 10–25% churn rate without over-predicting the majority class. The final score is the average of both models. - SHAP Explainability — For every prediction, SHAP (SHapley Additive exPlanations) values explain why the model scored a customer as high risk. This is critical for business adoption — stakeholders need to trust and understand predictions, not just receive a black-box score. Days-since-last-purchase tends to rank highest in SHAP importance because the churn definition itself is based on inactivity — this is by design, not a bug.
- Kaplan-Meier CLV — Customer Lifetime Value is estimated using survival analysis (the same statistical technique used in medical research to model "time to event"). This produces a realistic expected remaining lifetime for each customer, which is multiplied by their spending rate to get a dollar CLV estimate. Crucially, active customers who have reached the median lifetime floor at a minimum of one month remaining — not zero — because reaching the median just means they're survivors, not that they're about to leave.
- Customer Segmentation — Every customer is assigned to one of five segments (New, Healthy, At Risk, High Value, Churned) based on their churn probability and spending, with a recency-based fallback when no model has been trained yet.
- Predictions Dashboard Page — A "Train Model" button triggers the full pipeline, then displays the ROC curve, SHAP feature importance chart, a table of the 20 highest-risk customers, a segment distribution donut chart, and CLV summary cards. A privacy notice appears if customer IDs look like email addresses.
Why it matters: This is the core deliverable. Predicting churn with a well-engineered, explainable model — and presenting it clearly — is the difference between an analyst role and a data scientist role.
What it is: Projecting future revenue and subscriber counts 12 months ahead.
Knowing who churned is useful. Knowing how much revenue the business will generate next quarter is what the CFO needs for planning. This phase built a time-series forecasting system that:
- Projects monthly revenue and subscriber counts 12 months forward
- Produces confidence intervals (the forecast band the business should plan within)
- Uses Holt-Winters exponential smoothing (statsmodels) as the default — reliable, fast, no extra dependencies
- Optionally uses Prophet (Meta's forecasting library) as a swap-in alternative for seasonal data
What it is: Converting raw analytics into a plain-language executive briefing.
Instead of leaving analysts to interpret charts and numbers themselves, this phase generates a structured written summary of the business's retention health. The report covers five areas: business health overview, churn driver analysis, revenue outlook, customer segment breakdown, and specific recommended actions — all written in plain English with concrete numbers.
- Template mode (free) — Uses conditional logic and the actual computed values (churn rate, top SHAP feature, forecast totals, at-risk count) to generate a factual, non-generic briefing. Works with no external API key.
- AI mode (optional) — Add
ANTHROPIC_API_KEYorOPENAI_API_KEYto.envto have Claude or GPT write the narrative sections. The same data flows in; the language becomes more fluent. - Factory pattern —
get_insight_client()selects the right backend automatically based on available credentials. Switching from template to AI mode requires only adding an environment variable. - Automatic cache invalidation — The insights report resets whenever new data is uploaded or the model is retrained, so users always see analysis that matches the current state of the data.
Why it matters: Most dashboards require the reader to synthesise the story themselves. This phase demonstrates building a system that closes the loop between data and decision, which is the direction analytics tooling is moving.
What it is: Assembling all six analytics pages into a coherent product experience.
Phase 7 delivered the complete Streamlit multi-page application: navigation sidebar with state persistence across pages, polished Plotly chart layouts, download buttons for CSV exports (at-risk customers, CLV projections, full scored dataset) and a formatted PDF executive briefing, and an overview landing page that reads the session state to surface a live health summary before the user has trained any model.
Why it matters: Analytics engineering is not just models and data pipelines — it is the user experience that wraps them. A technically correct model buried in a bad interface does not get used.
What it is: Verifying the platform can be trusted with real business data.
- 94% test coverage (gate: 80%) — 283 tests across unit, integration, and edge-case suites
- Two production bug fixes discovered and resolved during test writing:
structlog.stdlib.add_logger_nameis incompatible withPrintLoggerFactory(no.nameattribute) — removed from processor chainfpdf2's built-in Helvetica font is Latin-1 only — added_to_latin1_safe()to normalize em-dashes, bullets, and smart quotes before PDF rendering
- 18-stage end-to-end integration test that runs the full business-logic pipeline on real sample data without Streamlit
- Security audit (
pip-audit) scoped to project dependencies — passes clean
Why it matters: Software that is not tested is not reliable. A portfolio project with a test suite and a coverage badge shows that the engineer thinks about correctness, not just features.
| Capability | Description | Technology |
|---|---|---|
| Data Ingestion | CSV/XLSX upload with schema validation and quality profiling | Pandas, Pydantic |
| Cohort Analysis | Monthly retention matrices, MRR trends, ARPU time series | Pandas, Plotly |
| Churn Prediction | LR + RF ensemble with SHAP explainability | scikit-learn, SHAP |
| CLV Modeling | Kaplan-Meier survival analysis for expected lifetime value | lifelines |
| Forecasting | 12-month revenue + subscriber forecasts with confidence bands | statsmodels / Prophet |
| AI Insights | Executive summaries and intervention recommendations | Anthropic / OpenAI / Template |
| Dashboard | Interactive multi-page Streamlit app | Streamlit, Plotly |
Choose the route that fits your setup. All four options end at the same app — pick whatever gets you there fastest.
No Python, Git, or Docker required. Everything runs in the browser.
- Click Code → Codespaces → Create codespace on master on the GitHub repo page.
- Wait ~2 minutes for the environment to build (installs all dependencies automatically).
- When the terminal is ready, run
streamlit run app.py. - A notification will appear at the bottom-right: "Your application running on port 8501 is available." Click Open in Browser.
- In the sidebar, click 📤 Upload Data. Then click 🎲 Load sample dataset instead — the full app loads immediately with 2,000 synthetic customers. No file to prepare.
The
.devcontainer/devcontainer.jsonconfigures Python 3.11, all dependencies, and the Streamlit port automatically.
Fork the repo and get a public URL in under 5 minutes.
- Fork this repository to your GitHub account.
- Go to share.streamlit.io and sign in with GitHub.
- Click Create app, select your fork, set Main file path to
app.py. - Under Advanced settings → Secrets, paste the contents of
.streamlit/secrets.toml.example(API keys are optional — the platform runs fully in template mode without them). - Click Deploy. Streamlit Cloud will build and serve the app at a public URL.
Streamlit Cloud injects secrets as environment variables — the Pydantic settings layer (
src/config/settings.py) reads them automatically. No code change needed to go from local.envto cloud.
Prerequisites: Docker Desktop installed and running.
git clone https://github.com/FomkaHamilton/churn-intelligence-platform.git
cd churn-intelligence-platform
cp .env.example .env # required — Docker Compose reads this file; API keys inside are optional
docker compose up --buildOpen http://localhost:8501. The first build takes ~3 minutes; subsequent starts are instant.
Note: The
cpstep is required even if you leave all API keys blank.docker composewill error if.envdoes not exist.
To stop: Ctrl+C, then docker compose down.
Prerequisites: Python 3.11 or 3.12, Git.
1. Clone and create a virtual environment:
git clone https://github.com/FomkaHamilton/churn-intelligence-platform.git
cd churn-intelligence-platform
python -m venv .venv2. Activate the virtual environment — pick the command for your shell:
# Mac / Linux
source .venv/bin/activate# Windows — PowerShell
.venv\Scripts\Activate.ps1# Windows — Command Prompt
.venv\Scripts\activate.bat3. Install dependencies and run:
pip install -r requirements.txt
cp .env.example .env # API keys inside are optional; file must exist
streamlit run app.pyOpen http://localhost:8501.
Windows PowerShell note: If
Activate.ps1is blocked, runSet-ExecutionPolicy -Scope CurrentUser RemoteSignedin PowerShell once, then retry.
All navigation is in the left sidebar. Follow the pages in order — each one feeds data into the next.
-
📤 Upload Data — Click 🎲 Load sample dataset instead (below the file uploader). This loads 2,000 synthetic customers and 22,000+ transactions instantly. To use your own data, upload a CSV with three columns:
customer_id,transaction_date,transaction_amount. -
📈 Analytics — Navigate here from the sidebar. KPI trends (MRR, ARPU, churn rate) and the cohort retention heatmap appear automatically. No button to click.
-
🤖 Predictions — Click 🚀 Train Churn Model. This runs the full ML pipeline (~10 seconds on sample data): churn prediction, SHAP explainability, Kaplan-Meier CLV, and customer segmentation. Results persist across page navigations.
-
🔮 Forecasting — A 12-month revenue and subscriber projection appears automatically once the model has been trained.
-
💡 Insights — The executive briefing generates automatically on your first visit. Click 🔄 Regenerate Insights to refresh it (for example, after changing the churn window in Settings). Uses template mode by default; add
ANTHROPIC_API_KEYorOPENAI_API_KEYto.envfor AI-written narratives. -
Export — Download buttons appear at the bottom of the Predictions page (CSV: at-risk list, CLV projections, full scored dataset) and the Insights page (PDF executive briefing).
The platform runs fully without an API key using built-in template mode. To enable AI-written insights:
# In .env:
ANTHROPIC_API_KEY=sk-ant-... # Claude (recommended)
# or
OPENAI_API_KEY=sk-... # GPT-4oThe factory (src/insights/factory.py) picks the right backend automatically. No other config change needed.
Screenshots will be added after the Streamlit Community Cloud deployment is live. The capture guide and annotation instructions (using Pillow) are in docs/screenshots/.
Pages covered: Upload + Quality Report, KPI Trends, Cohort Retention Heatmap, Churn Predictions + SHAP, CLV + Segments, 12-Month Forecast, AI Executive Briefing, PDF Export.
Three columns are required:
| Column | Type | Example |
|---|---|---|
customer_id |
string | CUST-00123 |
transaction_date |
date | 2024-03-15 |
transaction_amount |
decimal | 49.99 |
Optional enrichment columns:
| Column | What it unlocks |
|---|---|
subscription_id |
Subscription-level analysis |
country |
Geographic segmentation |
product |
Product-tier breakdown |
Common column name variants are remapped automatically (revenue, amount, user_id, order_date, etc.).
All tunable values live in config/settings.yaml:
churn:
window_days: 90 # days of inactivity that defines "churned" (UI-adjustable)
modeling:
test_size: 0.20 # temporal holdout fraction for model evaluation
forecasting:
horizon_months: 12
backend: "statsmodels" # swap to "prophet" for seasonal data (see requirements-optional.txt)
clv:
use_survival_analysis: trueAny value can be overridden via environment variable (e.g. CHURN_WINDOW_DAYS=60).
flowchart LR
A["CSV / XLSX\nUpload"] --> B["UploadEngine\ntype · size · injection"]
B --> C["SchemaValidator\ncolumn mapping"]
C --> D["DateFormatDetector\nauto-detect + confirm"]
D --> E["DataQualityChecker\nnulls · dupes · future dates"]
E --> F["KPICalculator\nMRR · ARPU · churn rate"]
E --> G["CohortAnalyzer\nretention matrix"]
E --> H["RFMBuilder\nrecency · frequency · monetary"]
H --> I["ChurnLabelBuilder\ngap-based · leakage-safe"]
I --> J["FeatureMatrixBuilder\ntemporal train/test split"]
J --> K["ChurnModel\nLR + RF ensemble"]
K --> L["SHAPExplainer\nfeature importance"]
K --> M["CLVModel\nKaplan-Meier survival"]
K --> N["CustomerSegmenter\n5 segments"]
F --> O["ForecastingPipeline\n12-month horizon"]
K --> P["InsightClient\nexecutive briefing"]
P --> Q["Export\nCSV · PDF"]
flowchart TD
A["app.py\nStreamlit entry"] --> B{"sidebar\nnavigation"}
B -->|Upload| C["pages/upload.py\nUploadEngine + Quality"]
B -->|Analytics| D["pages/analytics.py\nKPI + Cohort charts"]
B -->|Predictions| E["pages/predictions.py\nChurnModel + SHAP + CLV"]
B -->|Forecasting| F["pages/forecasting.py\nHolt-Winters / Prophet"]
B -->|Insights| G["pages/insights.py\nAI Executive Briefing"]
C -->|"st.session_state"| H[("df_clean\nrfm_result\nlabel_result\nkpi_snapshot")]
H --> D
H --> E
E -->|"st.session_state"| I[("model\npredictions\nclv_result\nsegments")]
I --> F
I --> G
churn-intelligence-platform/
├── app.py # Streamlit entry point (all pages)
├── Makefile # make install | run | test | lint | audit
├── Dockerfile # multi-stage production container
├── docker-compose.yml
│
├── config/
│ └── settings.yaml # all tunable parameters
│
├── src/
│ ├── config/ # Pydantic settings, shared constants
│ ├── utils/ # structured logging, exception hierarchy, types
│ ├── ingestion/ # file upload + injection detection
│ ├── preprocessing/ # cleaning, schema enforcement, date parsing
│ ├── feature_engineering/ # RFM features, churn label construction
│ ├── analytics/ # cohort retention, KPI calculations
│ ├── modeling/ # churn model, CLV, segmentation, SHAP
│ ├── forecasting/ # time-series forecasting backends
│ ├── insights/ # AI insight generation (Phase 6)
│ ├── visualization/ # Plotly chart builders (Streamlit-free)
│ └── database/ # SQLite session persistence
│
├── tests/
│ ├── unit/ # 264 unit tests across all modules
│ ├── integration/ # 18-stage end-to-end pipeline test
│ └── fixtures/ # synthetic data factories
│
└── data/
├── raw/ # uploaded files (gitignored)
├── processed/ # cleaned outputs (gitignored)
└── sample/ # synthetic demo data (committed)
├── generator.py # regenerate with: python data/sample/generator.py
└── subscriptions_sample.csv # 2,000 customers, 22k rows
make install # install all dependencies
make test # run tests with coverage report
make lint # ruff check + mypy type check
make format # auto-format with ruff
make sample-data # regenerate synthetic demo dataset
make audit # pip-audit security scanTest suite: 283 tests (unit + integration), all passing. Coverage: 94%.
| Phase | Status | What was built |
|---|---|---|
| 1 — Foundation | ✅ Complete | Repo scaffold, Pydantic config, structured logging, GitHub Actions CI, Docker |
| 2 — Data Layer | ✅ Complete | Upload engine, schema normalisation, date parser, quality checker, sample generator |
| 3 — Analytics Layer | ✅ Complete | RFM features, churn labels (leakage-protected), cohort retention matrix, KPI time series |
| 4 — ML Layer | ✅ Complete | Temporal train/test split, LR+RF ensemble, SHAP, Kaplan-Meier CLV, segmentation |
| 5 — Forecasting | ✅ Complete | Holt-Winters + Prophet forecasters, 12-month revenue/subscriber projections |
| 6 — AI Insights | ✅ Complete | Template + LLM insight clients, structured report, automatic cache invalidation |
| 7 — Dashboard | ✅ Complete | Full multi-page polish, state management, CSV + PDF exports |
| 8 — Hardening | ✅ Complete | 94% coverage (283 tests), 18-stage integration test, pip-audit clean |
| 9 — Portfolio Polish | ✅ Complete | Mermaid architecture diagrams, annotated screenshots, deployment config |
Why no subscription_status as a model feature?
Using a "status" column to predict churn is circular — the column already encodes the answer. The model would score 100% accuracy in testing and perform at random in production. A LABEL_LEAKAGE_COLUMNS blocklist enforces this at the code level and is tested in CI.
Why a temporal split instead of random?
Random train/test splits are incorrect for time-series data. If a customer joins in March and we train on their March data but test on their January data, we're predicting the past. The TimeSeriesChurnSplit class ensures the model is always evaluated on customers who were newer than everyone in the training set.
Why does recency dominate SHAP importance? Churn is defined as inactivity beyond the selected window (default: 90 days). Days-since-last-purchase encodes this directly, so it is expected to rank first. This is a deliberate modelling choice for subscription churn — recency is the primary behavioural signal — not a data leakage problem. The SHAP chart includes a note explaining this.
Why does CLV floor at 1 month for active customers?
The Kaplan-Meier median lifetime is the unconditional midpoint of the survival distribution. An active customer who has exceeded the median is not expected to churn immediately — they are demonstrably in the survivor tail. Clipping their expected remaining lifetime to zero (the naive max(0, median - tenure) formula) would produce a $0 CLV for some of the most loyal customers in the dataset, which is wrong. Active customers receive a minimum floor of one month of expected remaining value.
Why class_weight='balanced' instead of SMOTE?
SMOTE (synthetic oversampling) generates artificial data points which can introduce subtle distribution shifts. Sklearn's class_weight='balanced' adjusts the loss function directly — mathematically equivalent for most models, with no synthetic data artefacts.
Why no Prophet by default?
Prophet requires C++ build tools which can fail silently on Windows. Making it optional (in requirements-optional.txt) means the platform works out of the box on every OS while still supporting Prophet via a config flag for users who want seasonal modelling.
Why are src/pages/ and src/visualization/ excluded from coverage measurement?
Both packages import Streamlit at the module level and call st.plotly_chart, st.metric, st.dataframe, and similar rendering primitives. These functions require a live Streamlit server context — they cannot be invoked in a pytest session without mocking the entire Streamlit runtime, which tests the mock rather than the code. All the business logic those pages display (RFM scores, churn probabilities, SHAP values, CLV projections) is fully tested in the underlying analytics/, modeling/, forecasting/, and insights/ modules. Excluding pure rendering wrappers from coverage is standard practice — equivalent to omitting Django HTML templates or React components from a Python backend coverage report. The omit is explicit in pyproject.toml so the decision is visible and documented.
- Added
src/insights/package:InsightData,InsightReportdata contracts,BaseInsightClientABC,TemplateInsightClient,get_insight_client()factory - Added
src/visualization/insights.py: full Insights page renderer with health colour-coding - Added live Insights page to
app.pywith Regenerate button and template/AI mode banner - 30 new unit tests in
tests/unit/test_template_insights.py(212 total, all passing) - Fixed SHAP 3D array handling in
src/modeling/explainability.pyfor newer SHAP versions - Fixed SHAP feature name column access (
feature_importance["feature_name"]not.index)
- CLV bug fix (
src/modeling/clv.py): Active customers who exceed median lifetime now floor at 30 days remaining (1 month) instead of $0. Long-tenured paying customers were incorrectly showing zero CLV. - PII warning (
src/visualization/predictions.py): At-risk table now detects email-address-format customer IDs and displays a privacy notice before rendering. - Overview page logic (
app.pyline ~208): Fixed double-negationnot X is not None(always True) to the intendedX is not None. Success banner now correctly shows only when data is loaded. - Helper function ordering (
app.py): Moved_load_file,_show_date_confirmation,_finalise_uploadto before the page blocks so they are always defined on first Streamlit render, removing an edge-caseNameErrorrisk. - Insights cache invalidation (
app.py):insights_reportnow clears automatically when new data is uploaded or the model is retrained. A warning banner appears when the churn window changes while cached insights exist. - Recency transparency (
app.py): Added a caption below the SHAP importance chart explaining why recency ranks first by design. - Sample data (
data/sample/): Generatedsubscriptions_sample.csv(2,000 customers, 22,856 rows) so the demo works out of the box. Fixed month-boundary overflow bug ingenerator.py.
- Completed all six Streamlit pages: Upload, Analytics, Predictions, Forecasting, Insights, Overview
- Added
build_at_risk_csv,build_clv_csv,build_full_predictions_csv,build_insights_pdfexport functions (src/export.py) - Navigation sidebar with session state persistence across page transitions
- Overview page surfaces live health KPIs before the model is trained
- 94% test coverage (283 tests) — coverage gate set to 80% in
pyproject.toml - Added 71 new tests:
test_export.py,test_log.py,test_clv.py,test_explainability.py, plus gap tests intest_temporal_split.pyandtest_template_insights.py - Added 18-stage end-to-end integration test (
tests/integration/test_full_pipeline.py) - Bug fix: removed
structlog.stdlib.add_logger_namefrom processor chain — incompatible withPrintLoggerFactory(causedAttributeErrorat runtime and test-session poisoning) - Bug fix: added
_to_latin1_safe()+_LATIN1_MAPinsrc/export.py— fpdf2 built-in fonts are Latin-1 only; em-dash, bullet, and smart quotes previously causedUnicodeEncodeErrorin PDF export - Scoped
pip-auditto project requirements (not pip itself) — audit now passes clean
- Two Mermaid architecture diagrams in README (data pipeline + app routing), renders natively on GitHub
docs/screenshots/directory with 8-screenshot manifest and Pillow annotation guide.streamlit/config.toml(theme, XSRF protection, upload cap) for Streamlit Community Cloud.streamlit/secrets.toml.example— cloud secrets template; Pydantic settings reads them as env vars automatically- Coverage badge (94%), deployment instructions, screenshots section added to README
MIT — see LICENSE.
Built by FomkaHamilton as a public portfolio project demonstrating production-grade analytics engineering.