Compare commits

...

5 commits

Author SHA1 Message Date
Kisa
d197c529ce fix: demo readiness — filter flags, auth, active display, sidebar org, visit validation
- coverage.js: correct 7-tier flag names matching backend (was OUT_OF_COVERAGE etc.)
- Sidebar: dynamic org name from Clerk useOrganization (was hardcoded "Demo Supplier")
- App.jsx: fix undefined escalateCount/outreachCount crash
- Privacy.jsx: replace personal email with privacy@sttilsolutions.com
- api.js + ConfirmVisitModal: Confirm Visit uses Clerk Bearer token (was 401 on Vercel)
- WorklistTable: ACTIVE rows show next visit due date
- main.py: server-side 6-month qualifying visit validation
- CLAUDE.md: pilot readiness checklist updated to 65%
- pitch/legal/gaboro-nda.md: STTIL city + Robert Robinson email filled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:27:17 -04:00
Kisa
34e6c08c14 Mark Paste MCP Phase 1 complete
Content Outbox + Call Prep pinboards live. linkedin-post, linkedin-carousel,
and pilot-prep skills updated to push to Paste automatically. All 3 skills
passed spec compliance and code quality review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:31:57 -04:00
Kisa
aba7237de8 Add Paste MCP Phase 1 implementation plan
6 tasks: create 2 pinboards, update 3 skills (linkedin-post, linkedin-carousel,
pilot-prep), end-to-end verification. Skills dir is not a git repo — file edits only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 07:16:33 -04:00
Kisa
ee2d6b0308 Add pharmacy vs DME adherence stats and regulatory handicap framing to Brand Vault spec
DME wins on patient outcomes despite heavier Part B documentation burden vs pharmacy Part D.
Signal removes the handicap — the pitch framing belongs in the Brand Vault.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 07:12:00 -04:00
Kisa
43dcab2f68 Add Paste MCP ambient layer design spec
Four-phase architecture: Content Outbox + Call Prep (Phase 1, immediate),
Brand Vault (Phase 2), Session Handoff (Phase 3), Agent Trays (Phase 4).
Phase 1 starts now; Phase 3 gated on auth stability across 5+ sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:06:56 -04:00
12 changed files with 515 additions and 49 deletions

View file

@ -64,7 +64,7 @@ and stays predictable.
Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Projects/2026-05-18-pilot-readiness-whitepaper.md` Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Projects/2026-05-18-pilot-readiness-whitepaper.md`
### Checklist Status (as of 2026-06-07) — 45-50% pilot-ready ### Checklist Status (as of 2026-06-09) — 65% pilot-ready
| Checklist Item | Status | | Checklist Item | Status |
|---|---| |---|---|
@ -74,9 +74,9 @@ Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Project
| 25 CSV variants pass ingestion tests | PASS — 50 new files generated (PA + NJ sets), 100% normalizer pass rate 2026-06-07 | | 25 CSV variants pass ingestion tests | PASS — 50 new files generated (PA + NJ sets), 100% normalizer pass rate 2026-06-07 |
| Import flow: mapping, validation, warnings | PARTIAL — CSVImport exists, full mapping review unconfirmed | | Import flow: mapping, validation, warnings | PARTIAL — CSVImport exists, full mapping review unconfirmed |
| Report generation calculates from backend | PASS | | Report generation calculates from backend | PASS |
| Each status has reason + recommended action | PARTIAL — payer bug fixed; visit date still uses shipment proxy | | Each status has reason + recommended action | PASS — visit date proxy bug fixed 2026-06-09; `_resolve_visit_date()` implements full priority chain (CSV column > Supabase confirmed > shipment-30d estimate) |
| Export CSV works and opens cleanly | NOT VERIFIED end to end | | Export CSV works and opens cleanly | NOT VERIFIED end to end |
| Placeholder content removed | NOT VERIFIED | | Placeholder content removed | PARTIAL — StatCard undefined variables fixed 2026-06-09; Sidebar + sweep in progress |
| Browser smoke test passes | NOT RUN | | Browser smoke test passes | NOT RUN |
| Data handling rules documented | PASS — privacy-policy.md + data-handling.md written 2026-06-07; minimum necessary fields, CSV disposition policy, retention, and payer_rules.json cadence all documented | | Data handling rules documented | PASS — privacy-policy.md + data-handling.md written 2026-06-07; minimum necessary fields, CSV disposition policy, retention, and payer_rules.json cadence all documented |
| Real PHI blocked | PARTIAL — architecture confirmed; Supabase plan tier (Pro vs Team) not yet verified | | Real PHI blocked | PARTIAL — architecture confirmed; Supabase plan tier (Pro vs Team) not yet verified |

View file

@ -0,0 +1,247 @@
# Paste MCP Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire the Content Outbox and Call Prep pinboards into the linkedin-post, linkedin-carousel, and pilot-prep skills so every approved draft and call brief lands in Paste automatically.
**Architecture:** Two Paste pinboards are created first. Each skill gets one new instruction block at its natural completion point — after copy-review passes (linkedin-post), after final output is rendered (linkedin-carousel), and after the briefing is assembled (pilot-prep). Skills look up pinboard IDs by name via `mcp__paste__list_pinboards` at runtime rather than hardcoding IDs, so the wiring survives pinboard recreation.
**Tech Stack:** Paste MCP (`mcp__paste__create_pinboard`, `mcp__paste__list_pinboards`, `mcp__paste__create_item`), skill SKILL.md files (plain markdown instruction files, no code).
---
### Task 1: Create Content Outbox Pinboard
**Files:**
- No file changes — Paste MCP call only
- [ ] **Step 1: Create the pinboard**
Call `mcp__paste__create_pinboard` with name `Content Outbox`.
Expected response:
```json
{ "id": "content_outbox_XXXXXXXX-...", "name": "Content Outbox", "itemCount": 0 }
```
- [ ] **Step 2: Record the pinboard ID**
Note the `id` returned. It will be referenced in verification steps.
- [ ] **Step 3: Verify via list**
Call `mcp__paste__list_pinboards`. Confirm `Content Outbox` appears in the list alongside `Useful Links`.
---
### Task 2: Create Call Prep Pinboard
**Files:**
- No file changes — Paste MCP call only
- [ ] **Step 1: Create the pinboard**
Call `mcp__paste__create_pinboard` with name `Call Prep`.
Expected response:
```json
{ "id": "call_prep_XXXXXXXX-...", "name": "Call Prep", "itemCount": 0 }
```
- [ ] **Step 2: Verify via list**
Call `mcp__paste__list_pinboards`. Confirm both `Content Outbox` and `Call Prep` now appear.
- [ ] **Step 3: Commit a note**
```bash
cd /Users/sttil-solutions/projects/signal
git add docs/superpowers/plans/2026-06-09-paste-phase1.md
git commit -m "Add Paste Phase 1 implementation plan"
```
---
### Task 3: Update linkedin-post Skill
**Files:**
- Modify: `/Users/sttil-solutions/.claude/skills/linkedin-post/SKILL.md`
The current Step 6 handles NocoDB update after Kisa confirms the post is live. A new step goes between Step 5 (Iterate) and Step 6 (Confirm and Update NocoDB) — it fires when the draft is approved, not after posting. The Outbox is a staging layer: Kisa pastes from Paste to LinkedIn, then confirms to Claude, who updates NocoDB.
- [ ] **Step 1: Add the Paste push step**
In `/Users/sttil-solutions/.claude/skills/linkedin-post/SKILL.md`, insert the following block **between** the `## Step 5: Iterate` section and `## Step 6: Confirm and Update NocoDB`:
```markdown
---
## Step 5.5: Push Approved Draft to Paste Content Outbox
When Kisa says the draft is good (approval signals: "that works", "perfect", "use that", "looks good", or similar) — before waiting for posting confirmation:
1. Call `mcp__paste__list_pinboards` to get the current pinboard list.
2. Find the pinboard named `Content Outbox` and get its `id`.
3. Call `mcp__paste__create_item` with:
- `content`: the full approved post text (exactly as drafted — no extra formatting)
- `list_id`: the Content Outbox id from step 2
4. Confirm with one line: "Draft saved to Paste Content Outbox — paste directly to LinkedIn when ready."
Do not push unreviewed drafts. Only push after Kisa signals approval.
```
- [ ] **Step 2: Verify the file change**
Read the file. Confirm the new Step 5.5 block appears between Step 5 and Step 6. Confirm Step 6 is unchanged.
- [ ] **Step 3: Smoke test**
Invoke linkedin-post with a test prompt: "draft a short test post about documentation gaps."
After it drafts and copy-review passes, say "that works."
Confirm the skill calls `mcp__paste__list_pinboards` and then `mcp__paste__create_item`.
Call `mcp__paste__search` with `pinboard_id` set to the Content Outbox id — confirm the test draft appears.
Delete the test item via `mcp__paste__delete_item`.
- [ ] **Step 4: Confirm file saved**
Read the first 10 lines of `/Users/sttil-solutions/.claude/skills/linkedin-post/SKILL.md` to confirm the edit persisted. No git commit needed — `.claude/skills` is not a git repo.
---
### Task 4: Update linkedin-carousel Skill
**Files:**
- Modify: `/Users/sttil-solutions/.claude/skills/linkedin-carousel/SKILL.md`
The carousel output is a rendered PDF. The Paste push captures the text copy (hook, per-slide headlines and body) — not the PDF file. This gives Kisa a reviewable text record and reusable caption copy.
- [ ] **Step 1: Find the final output step**
Read `/Users/sttil-solutions/.claude/skills/linkedin-carousel/SKILL.md` in full. Identify the last step — the one where the rendered PDF path is reported to Kisa.
- [ ] **Step 2: Add the Paste push block**
Immediately after the final output/delivery step, append:
```markdown
---
## Final Step: Push Slide Copy to Paste Content Outbox
After confirming the PDF has rendered:
1. Assemble the carousel text copy in this format:
```
CAROUSEL: [Title]
HOOK: [Hook text from slide 1]
SLIDE 2: [Headline]
[Body copy if any]
SLIDE 3: [Headline]
[Body copy if any]
[... continue for all slides ...]
CTA: [Final slide CTA text]
```
2. Call `mcp__paste__list_pinboards`. Find `Content Outbox` and get its `id`.
3. Call `mcp__paste__create_item` with:
- `content`: the assembled text copy above
- `list_id`: the Content Outbox id
4. Confirm with one line: "Carousel copy saved to Paste Content Outbox. PDF at [path]."
```
- [ ] **Step 3: Verify the file change**
Read the file. Confirm the new block appears after the final delivery step and the format template is intact.
- [ ] **Step 4: Confirm file saved**
Read the last 20 lines of `/Users/sttil-solutions/.claude/skills/linkedin-carousel/SKILL.md` to confirm the new block is present.
---
### Task 5: Update pilot-prep Skill
**Files:**
- Modify: `/Users/sttil-solutions/.claude/skills/pilot-prep/SKILL.md`
The current Step 5 is "Offer a Practice Question (Optional)." The Paste push is a new Step 6 that fires after the briefing is assembled and the practice question is offered — so the full briefing is in Paste before the call, not just the summary.
- [ ] **Step 1: Add the Paste push step**
In `/Users/sttil-solutions/.claude/skills/pilot-prep/SKILL.md`, add the following block **after** `## Step 5: Offer a Practice Question (Optional)`:
```markdown
---
## Step 6: Push Briefing to Paste Call Prep
After delivering the briefing and the Step 5 practice question offer:
1. Call `mcp__paste__list_pinboards`. Find `Call Prep` and get its `id`.
2. Call `mcp__paste__create_item` with:
- `content`: the full briefing text (all sections from Step 3, exactly as delivered)
- `list_id`: the Call Prep id
3. Confirm with one line: "Briefing saved to Paste Call Prep — available in your clipboard on any device before the call."
This step is not optional. Push every briefing. Kisa clears the pinboard after the call.
```
- [ ] **Step 2: Verify the file change**
Read the file. Confirm Step 6 appears after Step 5. Confirm Steps 1-5 are unchanged.
- [ ] **Step 3: Smoke test**
Invoke pilot-prep with: "prep for my call with Robert Robinson."
After the briefing is delivered, confirm the skill calls `mcp__paste__list_pinboards` and `mcp__paste__create_item`.
Call `mcp__paste__search` with `pinboard_id` set to the Call Prep id — confirm the briefing appears.
Do not delete the test item — this is a real prep if Robert Robinson's call is upcoming.
- [ ] **Step 4: Confirm file saved**
Read the last 20 lines of `/Users/sttil-solutions/.claude/skills/pilot-prep/SKILL.md` to confirm Step 6 is present.
---
### Task 6: End-to-End Verification
- [ ] **Step 1: Verify pinboard state**
Call `mcp__paste__list_pinboards`. Confirm:
- `Content Outbox` exists
- `Call Prep` exists
- `Useful Links` still exists (default pinboard unchanged)
- [ ] **Step 2: Verify skill descriptions are updated**
Check that the `description:` frontmatter in each updated SKILL.md still accurately describes the skill. No change needed unless the description explicitly said "does not save anywhere."
- [ ] **Step 3: Update the spec to mark Phase 1 complete**
In `/Users/sttil-solutions/projects/signal/docs/superpowers/specs/2026-06-08-paste-mcp-ambient-layer-design.md`, update the Phase 1 status line to:
```markdown
### Phase 1: Content Outbox + Call Prep
**Trigger:** ~~Implement now~~ **COMPLETE — 2026-06-09**
```
- [ ] **Step 4: Final commit**
```bash
cd /Users/sttil-solutions/projects/signal
git add docs/superpowers/specs/2026-06-08-paste-mcp-ambient-layer-design.md
git commit -m "Mark Paste MCP Phase 1 complete"
```
---
## Notes
- Skills are instruction files (markdown), not application code. No unit tests apply. Verification is behavioral: invoke the skill, observe Paste.
- The `.claude/skills` directory may not be a git repo. Check with `git -C /Users/sttil-solutions/.claude/skills status` before committing. If not a repo, commits are skipped — file changes are sufficient.
- Paste auth must be active for smoke tests to work. If tools are unavailable, re-authenticate via `/mcp` in a separate Claude Code window before running verification steps.
- Phase 2 (Brand Vault) does not start until Phase 1 has been used across at least 2-3 real posts and calls and confirmed working.

View file

@ -0,0 +1,144 @@
# Paste MCP Ambient Layer — Design Spec
**Date:** 2026-06-08
**Scope:** STTIL Solutions infrastructure (all projects)
**Status:** Approved for Phase 1 implementation
---
## Problem
Every knowledge system in the STTIL stack (TriLane, MemPalace, Obsidian, NocoDB) requires a query. Content produced by Claude lives only in the chat window until manually copied. Call briefs generated by pilot-prep disappear after the session. There is no ambient layer — one that is always live, always accessible in every app, zero friction to use.
Paste MCP fills that gap. It is the bridge between what AI produces and where it actually gets used.
---
## Design Principle
Paste is always **downstream** of authoritative systems, never upstream.
- MemPalace KG: authoritative for brand facts
- NocoDB: authoritative for leads and content status
- TriLane: authoritative for deep research retrieval
- Paste: working copy of what is in active use right now
Paste holds the hot layer. It does not replace any deep storage system.
---
## Phased Architecture
### Phase 1: Content Outbox + Call Prep
**Trigger:** ~~Implement now~~ **COMPLETE — 2026-06-09**
**Pinboards created:**
- `Content Outbox` — staging layer for all publishable content
- `Call Prep` — pre-call brief for every pilot and investor call
**Content Outbox — how it works:**
1. linkedin-post skill finishes drafting → pushes draft to Content Outbox via `mcp__paste__create_item`
2. linkedin-carousel skill finishes drafting → same
3. Kisa reviews draft in Paste app, pastes directly to LinkedIn or wherever it's going
4. Claude updates NocoDB status to Published in the same move
5. Item deleted from Content Outbox — the pinboard stays clean, not an archive
**Call Prep — how it works:**
1. pilot-prep skill finishes → pushes call brief to Call Prep pinboard
2. Brief is in clipboard history before the call starts, accessible from any app or device
3. After the call, Granola pipeline runs as normal (unchanged)
4. Call Prep item cleared after the call
**Skills updated in Phase 1:**
- `linkedin-post`: add final step → `mcp__paste__create_item` to Content Outbox
- `linkedin-carousel`: add final step → `mcp__paste__create_item` to Content Outbox
- `pilot-prep`: add final step → `mcp__paste__create_item` to Call Prep
**Granola note:** Granola ingestion (post-call) is unaffected. The pipeline (Obsidian sync → TriLane → NavBot) stays exactly as is. Call Prep is pre-call only.
---
### Phase 2: Brand Vault
**Trigger:** After Phase 1 proves stable across multiple sessions.
**Pinboard created:** `Brand Vault`
**Contents (~12-15 items, all KG-verified):**
- CERT 2019 framing: "nearly 1 in 3" (32.8%), "over two-thirds from docs" (68.6%)
- CGS prepay stat: ~30.86% error rate, Q2 2024
- Badge labels: At Risk (SUPPLY_LAPSED/VISIT_REQUIRED/RENEWAL_CRITICAL), Action Needed (RENEWAL_ELEVATED/RENEWAL_SOON/TRANSFER_PENDING), Clear to Ship (RESUPPLY_READY), On Track (ACTIVE)
- Supply continuity naming: Supply Lapsed / Renewal Due / Resupply Ready / Active / Outreach Worklist
- Renewal Due cadence: 60d patient, 45d prescriber
- Pricing tiers: Worklist $149-199, Connected $349-499, Intelligence $699-999+usage
- Pitch anchor: "$400-500M/yr spent documenting the problem Signal solves; Worklist costs $1,788/yr"
- CMS PA exemption: ≥90% affirmation rate = PA exemption, June 1 2026 first cycle
- Pharmacy vs DME adherence (PMC11301113, 2,356 matched adults): 6mo DME 65% vs pharmacy 52%; 9mo DME 61% vs pharmacy 49%; 12mo DME 58.8% vs pharmacy 48% — pitch framing: "nearly half of pharmacy patients have lapsed by month 9"
- Medicare DME vs pharmacy (separate study): 78% vs 64% at 12 months; total annual costs 35% lower for DME ($6,967 vs $10,635)
- CRITICAL PITCH CONTEXT: Pharmacies carry significantly reduced documentation requirements vs DME (Part D prescription vs Part B CMN/PECOS/qualifying visit/PA). The DME adherence advantage is achieved DESPITE the regulatory handicap. Signal removes that handicap — protecting and extending an advantage the channel already earned. Never cite the adherence stats without this framing in a supplier or VC pitch.
- Brand sign-off block: "Signal. / Powered by STTIL Solutions"
**How it stays current:**
The Decision Protocol (CLAUDE.md) already triggers `kg_add` when a brand fact is confirmed. Phase 2 adds one step: after `kg_add`, update or create the corresponding Vault item in Paste. KG remains authoritative — Vault is the working copy.
**What it replaces:** The need to query me for locked stats and copy blocks during active work.
---
### Phase 3: Session Handoff
**Trigger:** After Paste auth proves reliable across 5+ consecutive sessions.
**Pinboard created:** `Hot Context`
**What it contains:** Curated ~200-word summary of what is in active motion — not history, not everything. Current build status, who is being called this week, what is blocked, immediate next actions.
**navaigate-session-brain update:** Add final step — push Hot Context summary to Paste.
**Session startup update (CLAUDE.md):** Add Paste Hot Context read at the top of the startup protocol. If Hot Context exists, skip `project_next_steps.md` and `project_vc_traction.md` reads — those become fallbacks for auth failures or empty pinboard. KG queries and NocoDB live pulls stay unchanged.
**Fallback rule:** If Paste auth fails or Hot Context is empty, the startup protocol falls through to the existing file reads with no change in behavior. Paste is never in the critical path until auth is proven.
**Token savings:** ~400-500 tokens per focused session. Startup speed improvement on days with a clear working agenda.
---
### Phase 4: Agent Trays
**Trigger:** After AiOS restructure completes and division agents are running.
**Pinboards created:** `Signal Tray`, `Content Tray`, `STTIL Tray`, `Infrastructure Tray`
**How it works:** Each AiOS division agent writes its current outputs and status to its tray. The morning skill reads all four trays as part of the briefing — two-sentence status per division, without triggering full agent runs.
**Scaling:** Med Tracker, UMSIS, and Game Center each get a tray pinboard when they activate. No redesign needed — add pinboard, wire agent, done.
---
## Full Pinboard Map
| Pinboard | Phase | Written by | Read by |
|----------|-------|-----------|---------|
| Content Outbox | 1 | linkedin-post, linkedin-carousel | Kisa (paste to publish) |
| Call Prep | 1 | pilot-prep | Kisa (during calls) |
| Brand Vault | 2 | KG update flow | Kisa, copy-review |
| Hot Context | 3 | navaigate-session-brain | Session startup protocol |
| Signal Tray | 4 | Signal agent | morning skill |
| Content Tray | 4 | Content agent | morning skill |
| STTIL Tray | 4 | STTIL agent | morning skill |
| Infrastructure Tray | 4 | Infrastructure agent | morning skill |
---
## What This Does NOT Change
- MemPalace KG: still authoritative for brand facts
- TriLane: still handles deep research retrieval and session logs
- NocoDB: still authoritative for leads and content calendar
- Granola pipeline: unchanged
- Memory .md files: permanent sections (preferences, feedback, paths) stay as-is; rolling sections (next steps, VC status) become fallbacks in Phase 3
---
## Pitfalls to Watch
1. **Auth reliability** — Paste auth does not yet persist reliably across sessions. Phase 3 is blocked until this proves stable. Phase 1 and 2 have no startup dependency and are safe to build now.
2. **Dual maintenance** — Brand Vault items must be written FROM KG, never independently. Any fact in Paste must trace back to a KG entry.
3. **Pinboard sprawl** — Keep pinboards small and curated. Paste is not a search system. If any pinboard grows past 20-25 items, it needs pruning or a sub-pinboard.
4. **Outbox hygiene** — Content Outbox must be cleared after each item is published. Stale drafts in the Outbox create confusion.

View file

@ -3,8 +3,8 @@
**Effective Date:** June __, 2026 **Effective Date:** June __, 2026
**Between:** **Between:**
- **STTIL Solutions LLC** ("STTIL"), a limited liability company, with its principal place of business in [City, State] - **STTIL Solutions LLC** ("STTIL"), a limited liability company, with its principal place of business in Jacksonville, Florida
- **Gaboro DME** ("Gaboro"), with its principal place of business in [City, State] - **Gaboro DME** ("Gaboro"), with its principal place of business in Philadelphia, Pennsylvania
Each a "Party" and together the "Parties." Each a "Party" and together the "Parties."
@ -118,4 +118,4 @@ By: ___________________________
Name: Robert Robinson Name: Robert Robinson
Title: Co-Founder and Managing Partner Title: Co-Founder and Managing Partner
Date: _________________________ Date: _________________________
Email: ___________________________ Email: robertr@gaboromed.com

View file

@ -469,6 +469,20 @@ async def confirm_visit(
if confirmed > date_type.today(): if confirmed > date_type.today():
raise HTTPException(status_code=400, detail="Confirmed date cannot be in the future.") raise HTTPException(status_code=400, detail="Confirmed date cannot be in the future.")
# Validate: visit must be within 6 months (183 days) before the order/shipment date.
# 183 days = ~6 months, consistent with CMS CGM qualifying visit window.
from datetime import timedelta as _td
six_months_before = shipment - _td(days=183)
if confirmed < six_months_before:
raise HTTPException(
status_code=400,
detail=(
f"Visit date {confirmed.isoformat()} is more than 6 months before "
f"the order date {shipment.isoformat()}. A qualifying visit must fall "
f"within the 6-month window before each supply order."
),
)
# Get org # Get org
clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
org_id = get_or_create_org(clerk_org_id=clerk_org_id) org_id = get_or_create_org(clerk_org_id=clerk_org_id)

View file

@ -29,8 +29,12 @@ function AppInner() {
const transferPending = records.filter((r) => r.flag === "TRANSFER_PENDING").length; const transferPending = records.filter((r) => r.flag === "TRANSFER_PENDING").length;
const refill = records.filter((r) => r.flag === "RESUPPLY_READY").length; const refill = records.filter((r) => r.flag === "RESUPPLY_READY").length;
const okCount = records.filter((r) => r.flag === "ACTIVE").length; const okCount = records.filter((r) => r.flag === "ACTIVE").length;
// escalateCount = VISIT_REQUIRED (qualifying visit past due) + RENEWAL_CRITICAL (<=45 days)
const escalateCount = visitRequired + renewalCritical; const escalateCount = visitRequired + renewalCritical;
const outreachCount = renewalSoon + renewalElevated; // outreachCount = RENEWAL_ELEVATED (<=60d) + RENEWAL_SOON (<=90d)
const outreachCount = renewalElevated + renewalSoon;
const atRiskCount = supplyLapsed + visitRequired + renewalCritical;
const actionNeededCount = renewalSoon + renewalElevated + transferPending;
const handleResults = useCallback(async (file) => { const handleResults = useCallback(async (file) => {
const label = new Date().toLocaleDateString("en-US", { const label = new Date().toLocaleDateString("en-US", {
@ -60,7 +64,7 @@ function AppInner() {
const rows = parseCSV(e.target.result); const rows = parseCSV(e.target.result);
const { results, skipped } = processBatch(rows); const { results, skipped } = processBatch(rows);
setRecords(results); setRecords(results);
setImportLabel(`${file.name} · ${label} · local processing`); setImportLabel(`${file.name} · ${label}`);
let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`; let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`;
if (skipped.length) msg += ` · ${skipped.length} skipped`; if (skipped.length) msg += ` · ${skipped.length} skipped`;
showToast(msg); showToast(msg);
@ -72,9 +76,10 @@ function AppInner() {
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors"> <div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
{/* Sidebar */} {/* Sidebar */}
<Sidebar <Sidebar
supplyLapsedCount={supplyLapsed} atRiskCount={atRiskCount}
escalateCount={escalateCount} actionNeededCount={actionNeededCount}
clearToShipCount={refill} clearToShipCount={refill}
onTrackCount={okCount}
activeFilter={activeFilter} activeFilter={activeFilter}
onFilterChange={setActiveFilter} onFilterChange={setActiveFilter}
onImportClick={() => csvImportRef.current?.trigger()} onImportClick={() => csvImportRef.current?.trigger()}
@ -113,8 +118,8 @@ function AppInner() {
<StatCard <StatCard
priority priority
label="Needs Attention" label="Needs Attention"
value={supplyLapsed + escalateCount + transferPending} value={supplyLapsed + visitRequired + renewalCritical + transferPending}
sub={`${supplyLapsed} docs required · ${escalateCount} escalate · ${transferPending} new patient`} sub={`${supplyLapsed} docs required · ${visitRequired + renewalCritical} escalate · ${transferPending} new patient`}
/> />
<StatCard <StatCard
label="Outreach in Progress" label="Outreach in Progress"

View file

@ -130,10 +130,10 @@ export default function Privacy() {
<br /> <br />
Email:{" "} Email:{" "}
<a <a
href="mailto:kisasttil@gmail.com" href="mailto:privacy@sttilsolutions.com"
className="text-[var(--brand,#147A7A)] underline underline-offset-2" className="text-[var(--brand,#147A7A)] underline underline-offset-2"
> >
kisasttil@gmail.com privacy@sttilsolutions.com
</a> </a>
</p> </p>
</section> </section>
@ -143,10 +143,10 @@ export default function Privacy() {
<p className="text-[11px] text-[var(--text-muted,#7A9E9E)] leading-[1.6]"> <p className="text-[11px] text-[var(--text-muted,#7A9E9E)] leading-[1.6]">
Signal is operated by STTIL Solutions LLC.{" "} Signal is operated by STTIL Solutions LLC.{" "}
<a <a
href="https://signal.sttilsolutions.com" href="https://sttilsolutions.com/signal"
className="text-[var(--brand,#147A7A)] underline underline-offset-2" className="text-[var(--brand,#147A7A)] underline underline-offset-2"
> >
signal.sttilsolutions.com sttilsolutions.com/signal
</a> </a>
{" "}· We may update this policy from time to time. We will notify active customers of material {" "}· We may update this policy from time to time. We will notify active customers of material
changes by email. Continued use of Signal following notice constitutes acceptance. changes by email. Continued use of Signal following notice constitutes acceptance.

View file

@ -1,7 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@clerk/react";
import { confirmVisit } from "../lib/api"; import { confirmVisit } from "../lib/api";
export default function ConfirmVisitModal({ record, onClose, onConfirmed }) { export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
const { getToken } = useAuth();
const [visitDate, setVisitDate] = useState(""); const [visitDate, setVisitDate] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -29,9 +31,20 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
setError("Visit date cannot be in the future."); setError("Visit date cannot be in the future.");
return; return;
} }
// Validate: visit must be within 6 months before the order/shipment date
const shipmentDateStr = computeShipmentDate();
const shipmentDateObj = new Date(shipmentDateStr + "T00:00:00");
const visitDateObj = new Date(visitDate + "T00:00:00");
const sixMonthsBeforeShipment = new Date(shipmentDateObj);
sixMonthsBeforeShipment.setMonth(sixMonthsBeforeShipment.getMonth() - 6);
if (visitDateObj < sixMonthsBeforeShipment) {
setError("Visit date must be within 6 months of the order date. A visit older than 6 months does not satisfy the qualifying visit requirement.");
return;
}
setSaving(true); setSaving(true);
try { try {
const token = await getToken().catch(() => null);
const updated = await confirmVisit({ const updated = await confirmVisit({
patient_id: record.patient_id, patient_id: record.patient_id,
confirmed_date: visitDate, confirmed_date: visitDate,
@ -40,7 +53,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
device_type: record.device_type, device_type: record.device_type,
quantity: 1, quantity: 1,
component: record.component || "sensor", component: record.component || "sensor",
}); }, token);
onConfirmed(record.patient_id, updated); onConfirmed(record.patient_id, updated);
onClose(); onClose();
} catch (e) { } catch (e) {

View file

@ -1,11 +1,23 @@
import { useOrganization } from "@clerk/react";
export default function Sidebar({ export default function Sidebar({
supplyLapsedCount, atRiskCount,
escalateCount, actionNeededCount,
clearToShipCount, clearToShipCount,
onTrackCount,
activeFilter, activeFilter,
onFilterChange, onFilterChange,
onImportClick, onImportClick,
}) { }) {
const { organization } = useOrganization();
const orgName = organization?.name ?? "My Organization";
const initials = orgName
.split(" ")
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
const navItems = [ const navItems = [
{ {
label: "All Patients", label: "All Patients",
@ -14,17 +26,17 @@ export default function Sidebar({
badge: null, badge: null,
}, },
{ {
label: "Docs Required", label: "At Risk",
key: "SUPPLY_LAPSED", key: "at-risk",
icon: "!", icon: "!",
badge: supplyLapsedCount, badge: atRiskCount,
badgeWarn: true, badgeWarn: true,
}, },
{ {
label: "Escalate", label: "Action Needed",
key: "escalate", key: "action-needed",
icon: "!", icon: "!",
badge: escalateCount, badge: actionNeededCount,
badgeWarn: true, badgeWarn: true,
}, },
{ {
@ -33,6 +45,12 @@ export default function Sidebar({
icon: "✓", icon: "✓",
badge: clearToShipCount, badge: clearToShipCount,
}, },
{
label: "On Track",
key: "ACTIVE",
icon: "✓",
badge: onTrackCount,
},
]; ];
return ( return (
@ -116,11 +134,11 @@ export default function Sidebar({
<div className="px-5 py-[14px] border-t border-[rgba(15,94,94,0.5)]"> <div className="px-5 py-[14px] border-t border-[rgba(15,94,94,0.5)]">
<div className="flex items-center gap-[10px]"> <div className="flex items-center gap-[10px]">
<div className="w-[30px] h-[30px] bg-[rgba(46,163,163,0.18)] border border-[rgba(46,163,163,0.3)] rounded-full flex items-center justify-center text-[11px] text-teal-400 font-semibold shrink-0"> <div className="w-[30px] h-[30px] bg-[rgba(46,163,163,0.18)] border border-[rgba(46,163,163,0.3)] rounded-full flex items-center justify-center text-[11px] text-teal-400 font-semibold shrink-0">
DS {initials}
</div> </div>
<div> <div>
<div className="text-xs font-medium text-warm-white"> <div className="text-xs font-medium text-warm-white">
Demo Supplier {orgName}
</div> </div>
<div className="text-[10px] text-teal-300 mt-[1px]"> <div className="text-[10px] text-teal-300 mt-[1px]">
Billing Staff Billing Staff

View file

@ -162,6 +162,11 @@ export default function WorklistTable({ records, activeFilter, onFilterChange })
{r.reason} {r.reason}
</div> </div>
)} )}
{r.flag === "ACTIVE" && r.next_visit_due_date && (
<div className="text-[10.5px] text-[var(--text-muted)] mt-[4px]">
Next visit due: {new Date(r.next_visit_due_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", year: "numeric" })}
</div>
)}
</td> </td>
<td className="px-[22px] py-[13px] align-middle"> <td className="px-[22px] py-[13px] align-middle">
<span className={`font-mono text-[16px] font-medium ${scoreClass(priority)}`}> <span className={`font-mono text-[16px] font-medium ${scoreClass(priority)}`}>

View file

@ -92,8 +92,10 @@ export function apiRecordToLocal(r) {
* Confirm a qualifying visit date for a patient. * Confirm a qualifying visit date for a patient.
* Returns the updated RecordOut from the backend. * Returns the updated RecordOut from the backend.
*/ */
export async function confirmVisit(payload) { export async function confirmVisit(payload, token = null) {
const authHeader = API_KEY ? { "X-API-Key": API_KEY } : {}; const authHeader = token
? { "Authorization": `Bearer ${token}` }
: API_KEY ? { "X-API-Key": API_KEY } : {};
try { try {
const res = await fetch(`${BACKEND_URL}/api/confirm-visit`, { const res = await fetch(`${BACKEND_URL}/api/confirm-visit`, {
method: "POST", method: "POST",

View file

@ -7,24 +7,33 @@
*/ */
export const FLAG = { export const FLAG = {
OUT_OF_COVERAGE: "OUT_OF_COVERAGE", SUPPLY_LAPSED: "SUPPLY_LAPSED",
VISIT_DUE: "VISIT_DUE", VISIT_REQUIRED: "VISIT_REQUIRED",
REFILL_WINDOW: "REFILL_WINDOW", RENEWAL_CRITICAL: "RENEWAL_CRITICAL",
OK: "OK", RENEWAL_ELEVATED: "RENEWAL_ELEVATED",
RENEWAL_SOON: "RENEWAL_SOON",
RESUPPLY_READY: "RESUPPLY_READY",
ACTIVE: "ACTIVE",
}; };
const FLAG_LABELS = { const FLAG_LABELS = {
[FLAG.OUT_OF_COVERAGE]: "Supply Lapsed", [FLAG.SUPPLY_LAPSED]: "Supply Lapsed",
[FLAG.VISIT_DUE]: "Renewal Due", [FLAG.VISIT_REQUIRED]: "Visit Required",
[FLAG.REFILL_WINDOW]: "Resupply Ready", [FLAG.RENEWAL_CRITICAL]: "Renewal Due",
[FLAG.OK]: "Active", [FLAG.RENEWAL_ELEVATED]: "Renewal Elevated",
[FLAG.RENEWAL_SOON]: "Renewal Soon",
[FLAG.RESUPPLY_READY]: "Resupply Ready",
[FLAG.ACTIVE]: "Active",
}; };
const FLAG_ACTIONS = { const FLAG_ACTIONS = {
[FLAG.OUT_OF_COVERAGE]: "Contact Prescriber", [FLAG.SUPPLY_LAPSED]: "Contact Prescriber",
[FLAG.VISIT_DUE]: "Request Renewal", [FLAG.VISIT_REQUIRED]: "Schedule Visit",
[FLAG.REFILL_WINDOW]: "Initiate Resupply", [FLAG.RENEWAL_CRITICAL]: "Request Renewal",
[FLAG.OK]: "No action needed", [FLAG.RENEWAL_ELEVATED]: "Request Renewal",
[FLAG.RENEWAL_SOON]: "Plan Renewal",
[FLAG.RESUPPLY_READY]: "Initiate Resupply",
[FLAG.ACTIVE]: "No action needed",
}; };
/** Wear-day rules — mirrors payer_rules.json */ /** Wear-day rules — mirrors payer_rules.json */
@ -79,9 +88,12 @@ function getPayerConfig(payer) {
} }
function computePriority(flag, daysUntilEnd) { function computePriority(flag, daysUntilEnd) {
if (flag === FLAG.OUT_OF_COVERAGE) return 1000 + Math.abs(daysUntilEnd); if (flag === FLAG.SUPPLY_LAPSED) return 1000 + Math.abs(daysUntilEnd);
if (flag === FLAG.VISIT_DUE) return 500 + Math.max(0, 90 - daysUntilEnd); if (flag === FLAG.VISIT_REQUIRED) return 1500 + Math.abs(daysUntilEnd);
if (flag === FLAG.REFILL_WINDOW) return 200 + Math.max(0, 30 - daysUntilEnd); if (flag === FLAG.RENEWAL_CRITICAL) return 500 + Math.max(0, 90 - daysUntilEnd);
if (flag === FLAG.RENEWAL_ELEVATED) return 700 + Math.max(0, 90 - daysUntilEnd);
if (flag === FLAG.RENEWAL_SOON) return 400 + Math.max(0, 90 - daysUntilEnd);
if (flag === FLAG.RESUPPLY_READY) return 200 + Math.max(0, 30 - daysUntilEnd);
return 0; return 0;
} }
@ -109,13 +121,19 @@ export function calculateCoverage(record) {
let flag; let flag;
if (daysUntilEnd < 0) { if (daysUntilEnd < 0) {
flag = FLAG.OUT_OF_COVERAGE; flag = FLAG.SUPPLY_LAPSED;
} else if (daysUntilVisit !== null && daysUntilVisit <= 30) { } else if (daysUntilVisit !== null && daysUntilVisit < 0) {
flag = FLAG.VISIT_DUE; flag = FLAG.VISIT_REQUIRED;
} else if (daysUntilVisit !== null && daysUntilVisit <= 45) {
flag = FLAG.RENEWAL_CRITICAL;
} else if (daysUntilVisit !== null && daysUntilVisit <= 60) {
flag = FLAG.RENEWAL_ELEVATED;
} else if (daysUntilVisit !== null && daysUntilVisit <= 90) {
flag = FLAG.RENEWAL_SOON;
} else if (daysUntilEnd <= payerCfg.refillWindowDays) { } else if (daysUntilEnd <= payerCfg.refillWindowDays) {
flag = FLAG.REFILL_WINDOW; flag = FLAG.RESUPPLY_READY;
} else { } else {
flag = FLAG.OK; flag = FLAG.ACTIVE;
} }
return { return {