From dddb1ef27eb9e669dfbe1e35037cb77f3cc70288 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:17:41 -0500 Subject: task: sync database --- .tasks/tasks.jsonl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index d3dadd0..1a022f9 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -153,3 +153,7 @@ {"taskCreatedAt":"2025-11-21T03:12:57.890285833Z","taskDependencies":[],"taskId":"t-rWa7IYOrq","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Parent Epic","taskType":"Epic","taskUpdatedAt":"2025-11-21T03:12:57.890285833Z"} {"taskCreatedAt":"2025-11-21T03:13:01.031231982Z","taskDependencies":[],"taskId":"t-rWa7IYOrq.1","taskNamespace":null,"taskParent":"t-rWa7IYOrq","taskPriority":"P2","taskStatus":"Open","taskTitle":"Child Task","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T03:13:01.031231982Z"} {"taskCreatedAt":"2025-11-21T04:09:41.699239296Z","taskDependencies":[],"taskId":"t-rWabrkQDQ","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix task ready to exclude Review tasks","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:10:49.102675623Z"} +{"taskCreatedAt":"2025-11-13T19:38:07.804316976Z","taskDependencies":[],"taskId":"t-1f9QP23","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"General Code Quality Refactor","taskType":"Epic","taskUpdatedAt":"2025-11-13T19:38:07.804316976Z"} +{"taskCreatedAt":"2025-11-20T21:41:20.029426381Z","taskDependencies":[],"taskId":"t-1ne7Qtj","taskNamespace":"Network/Wai/Middleware/Braid.hs","taskParent":"t-1f9QP23","taskPriority":"P2","taskStatus":"Open","taskTitle":"Implement Braid keep-alive mechanism","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T21:41:20.029426381Z"} +{"taskCreatedAt":"2025-11-20T21:41:20.048368004Z","taskDependencies":[],"taskId":"t-1ne7VoO","taskNamespace":"Biz/Que/Host.hs","taskParent":"t-1f9QP23","taskPriority":"P2","taskStatus":"Open","taskTitle":"Revive authkey authentication in Que/Host","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T21:41:20.048368004Z"} +{"taskCreatedAt":"2025-11-20T21:41:20.067644599Z","taskDependencies":[],"taskId":"t-1ne80pJ","taskNamespace":"Biz/Dragons.hs","taskParent":"t-1f9QP23","taskPriority":"P2","taskStatus":"Done","taskTitle":"Store generated JWK in persistent file","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T22:54:17.655700806Z"} -- cgit v1.2.3 From f86a62a20d77e72e49d08ef35d0db8fbd482cbc5 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:23:46 -0500 Subject: task: sync database --- .tasks/tasks.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index 1a022f9..b260274 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -45,7 +45,7 @@ {"taskCreatedAt":"2025-11-13T16:32:17.411379982Z","taskDependencies":[],"taskId":"t-12ZeUsG","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update success/cancel URLs to redirect to / instead of /billing","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:36:41.808119038Z"} {"taskCreatedAt":"2025-11-13T16:32:17.557115348Z","taskDependencies":[],"taskId":"t-12Zfwnf","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove 'Billing' button from navbar (paid users will use Stripe portal link in callout)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:34:44.628587871Z"} {"taskCreatedAt":"2025-11-13T16:32:17.738052991Z","taskDependencies":[],"taskId":"t-12ZghrB","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Test the complete flow and verify all changes","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:37:49.356932049Z"} -{"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"InProgress","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:17:50.66522286Z"} +{"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:23:46.372194168Z"} {"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Open","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:38:08.176692694Z"} {"taskCreatedAt":"2025-11-13T19:38:08.37344762Z","taskDependencies":[],"taskId":"t-1f9Td4U","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Open","taskTitle":"Navbar Styling Cleanup","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:38:08.37344762Z"} {"taskCreatedAt":"2025-11-13T19:38:32.95559213Z","taskDependencies":[],"taskId":"t-1fbym1M","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove BLE001 noqa for bare Exception catches - use specific exceptions","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:43:29.049855419Z"} -- cgit v1.2.3 From 553cbe1122958f31adba0225871fcc6785669cdb Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:23:56 -0500 Subject: task: sync database --- .tasks/tasks.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index b260274..b44252f 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -46,7 +46,7 @@ {"taskCreatedAt":"2025-11-13T16:32:17.557115348Z","taskDependencies":[],"taskId":"t-12Zfwnf","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove 'Billing' button from navbar (paid users will use Stripe portal link in callout)","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:34:44.628587871Z"} {"taskCreatedAt":"2025-11-13T16:32:17.738052991Z","taskDependencies":[],"taskId":"t-12ZghrB","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Test the complete flow and verify all changes","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T16:37:49.356932049Z"} {"taskCreatedAt":"2025-11-13T19:38:08.01779309Z","taskDependencies":[],"taskId":"t-1f9RIzd","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Review","taskTitle":"Account Management Page","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:23:46.372194168Z"} -{"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Open","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:38:08.176692694Z"} +{"taskCreatedAt":"2025-11-13T19:38:08.176692694Z","taskDependencies":[],"taskId":"t-1f9SnU7","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"InProgress","taskTitle":"Queue Status Improvements","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T04:23:56.308855214Z"} {"taskCreatedAt":"2025-11-13T19:38:08.37344762Z","taskDependencies":[],"taskId":"t-1f9Td4U","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-1vIPJYG","taskPriority":"P2","taskStatus":"Open","taskTitle":"Navbar Styling Cleanup","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:38:08.37344762Z"} {"taskCreatedAt":"2025-11-13T19:38:32.95559213Z","taskDependencies":[],"taskId":"t-1fbym1M","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Remove BLE001 noqa for bare Exception catches - use specific exceptions","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:43:29.049855419Z"} {"taskCreatedAt":"2025-11-13T19:38:33.139120541Z","taskDependencies":[],"taskId":"t-1fbz7LV","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix PLR0913 violations - refactor functions with too many parameters","taskType":"WorkTask","taskUpdatedAt":"2025-11-13T19:44:09.820023426Z"} -- cgit v1.2.3 From c7cc9b727075566edc83cb25de31aeb3afb0f02a Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:26:18 -0500 Subject: Implement Account Management Page Amp-Thread-ID: https://ampcode.com/threads/T-51eb1377-abce-430c-bde5-ef909ac79444 Co-authored-by: Amp --- Biz/PodcastItLater/UI.py | 167 +++++++++++++++++++++++++++++++++++ Biz/PodcastItLater/Web.py | 219 +++++++++++++--------------------------------- 2 files changed, 228 insertions(+), 158 deletions(-) diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index 27f5fff..4650708 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -407,6 +407,173 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): ) +class AccountPageAttrs(Attrs): + """Attributes for AccountPage component.""" + + user: dict[str, typing.Any] + usage: dict[str, int] + limits: dict[str, int | None] + portal_url: str | None + + +class AccountPage(Component[AnyChildren, AccountPageAttrs]): + """Account management page component.""" + + @override + def render(self) -> PageLayout: + user = self.attrs["user"] + usage = self.attrs["usage"] + limits = self.attrs["limits"] + portal_url = self.attrs["portal_url"] + + plan_tier = user.get("plan_tier", "free") + is_paid = plan_tier == "paid" + + article_limit = limits.get("articles_per_period") + article_usage = usage.get("articles", 0) + + limit_text = "Unlimited" if article_limit is None else str(article_limit) + + return PageLayout( + user=user, + current_page="account", + page_title="Account - PodcastItLater", + error=None, + meta_tags=[], + children=[ + html.div( + html.div( + html.div( + html.div( + html.h2( + html.i( + classes=[ + "bi", + "bi-person-circle", + "me-2" + ] + ), + "My Account", + classes=["card-title", "mb-4"], + ), + # User Info Section + html.div( + html.h5("Profile", classes=["mb-3"]), + html.p( + html.strong("Email: "), + user.get("email", ""), + classes=["mb-2"], + ), + html.p( + html.strong("Member since: "), + user.get("created_at", "").split("T")[0], + classes=["mb-4"], + ), + classes=["mb-5"], + ), + # Subscription Section + html.div( + html.h5("Subscription", classes=["mb-3"]), + html.div( + html.div( + html.strong("Current Plan"), + html.span( + plan_tier.title(), + classes=[ + "badge", + "bg-success" + if is_paid + else "bg-secondary", + "ms-2", + ], + ), + classes=[ + "d-flex", + "align-items-center", + "mb-3", + ], + ), + # Usage Stats + html.div( + html.p( + "Usage this period:", + classes=["mb-2", "text-muted"], + ), + html.div( + html.div( + f"{article_usage} / {limit_text}", + classes=["mb-1"], + ), + html.div( + html.div( + classes=["progress-bar"], + role="progressbar", + style={ + "width": f"{min(100, (article_usage / article_limit * 100))}%" + } if article_limit else {"width": "0%"}, + ), + classes=["progress", "mb-3"], + style={"height": "10px"}, + ) if article_limit else html.div(), + classes=["mb-3"], + ), + ), + # Actions + html.div( + html.form( + html.button( + html.i(classes=["bi", "bi-credit-card", "me-2"]), + "Manage Subscription", + type="submit", + classes=["btn", "btn-outline-primary"], + ), + method="POST", + action=portal_url, + ) if is_paid and portal_url else + html.a( + html.i(classes=["bi", "bi-star-fill", "me-2"]), + "Upgrade to Pro", + href="/pricing", + classes=["btn", "btn-primary"], + ), + classes=["d-flex", "gap-2"], + ), + classes=["card", "card-body", "bg-light"], + ), + classes=["mb-5"], + ), + # Logout Section + html.div( + html.form( + html.button( + html.i( + classes=[ + "bi", + "bi-box-arrow-right", + "me-2" + ] + ), + "Log Out", + type="submit", + classes=["btn", "btn-outline-danger"], + ), + action="/logout", + method="POST", + ), + classes=["border-top", "pt-4"], + ), + classes=["card-body", "p-4"], + ), + classes=["card", "shadow-sm"], + ), + classes=["col-lg-8", "mx-auto"], + ), + classes=["row"], + ), + ], + ) + + class PricingPageAttrs(Attrs): """Attributes for PricingPage component.""" diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 7e8e969..044cdca 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -362,6 +362,9 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): status_classes = { "pending": "bg-warning text-dark", "processing": "bg-primary", + "extracting": "bg-info text-dark", + "synthesizing": "bg-primary", + "uploading": "bg-success", "error": "bg-danger", "cancelled": "bg-secondary", } @@ -369,6 +372,9 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): status_icons = { "pending": "bi-clock", "processing": "bi-arrow-repeat", + "extracting": "bi-file-text", + "synthesizing": "bi-mic", + "uploading": "bi-cloud-arrow-up", "error": "bi-exclamation-triangle", "cancelled": "bi-x-circle", } @@ -1148,7 +1154,7 @@ def verify_magic_link(request: Request) -> Response: @app.get("/account") -def account_page(request: Request) -> UI.PageLayout | RedirectResponse: +def account_page(request: Request) -> UI.AccountPage | RedirectResponse: """Account management page.""" user_id = request.session.get("user_id") if not user_id: @@ -1158,165 +1164,19 @@ def account_page(request: Request) -> UI.PageLayout | RedirectResponse: if not user: return RedirectResponse(url="/?error=user_not_found") - # Get subscription details + # Get usage stats + period_start, period_end = Billing.get_period_boundaries(user) + usage = Billing.get_usage(user["id"], period_start, period_end) + + # Get limits tier = user.get("plan_tier", "free") - tier_info = Billing.get_tier_info(tier) - subscription_status = user.get("subscription_status", "") - cancel_at_period_end = user.get("cancel_at_period_end", 0) == 1 - - return UI.PageLayout( - html.h2( - html.i( - classes=["bi", "bi-person-circle", "me-2"], - ), - "Account Management", - classes=["mb-4"], - ), - html.div( - html.h4( - html.i(classes=["bi", "bi-envelope-fill", "me-2"]), - "Account Information", - classes=["card-header", "bg-transparent"], - ), - html.div( - html.div( - html.strong("Email: "), - user["email"], - classes=["mb-2"], - ), - html.div( - html.strong("Account Created: "), - user["created_at"], - classes=["mb-2"], - ), - classes=["card-body"], - ), - classes=["card", "mb-4"], - ), - html.div( - html.h4( - html.i( - classes=["bi", "bi-credit-card-fill", "me-2"], - ), - "Subscription", - classes=["card-header", "bg-transparent"], - ), - html.div( - html.div( - html.strong("Plan: "), - tier_info["name"], - f" ({tier_info['price']})", - classes=["mb-2"], - ), - html.div( - html.strong("Status: "), - subscription_status.title() - if subscription_status - else "Active", - classes=["mb-2"], - ) - if tier == "paid" - else html.div(), - html.div( - html.i( - classes=[ - "bi", - "bi-info-circle", - "me-1", - ], - ), - "Your subscription will cancel at the end " - "of the billing period.", - classes=[ - "alert", - "alert-warning", - "mt-2", - "mb-2", - ], - ) - if cancel_at_period_end - else html.div(), - html.div( - html.strong("Features: "), - tier_info["description"], - classes=["mb-3"], - ), - html.div( - html.a( - html.i( - classes=[ - "bi", - "bi-arrow-up-circle", - "me-1", - ], - ), - "Upgrade to Paid Plan", - href="#", - hx_post="/billing/checkout", - hx_vals='{"tier": "paid"}', - classes=[ - "btn", - "btn-success", - "me-2", - ], - ) - if tier == "free" - else html.form( - html.button( - html.i( - classes=[ - "bi", - "bi-gear-fill", - "me-1", - ], - ), - "Manage Subscription", - type="submit", - classes=[ - "btn", - "btn-primary", - "me-2", - ], - ), - method="post", - action="/billing/portal", - ), - ), - classes=["card-body"], - ), - classes=["card", "mb-4"], - ), - html.div( - html.h4( - html.i(classes=["bi", "bi-sliders", "me-2"]), - "Actions", - classes=["card-header", "bg-transparent"], - ), - html.div( - html.a( - html.i( - classes=[ - "bi", - "bi-box-arrow-right", - "me-1", - ], - ), - "Logout", - href="/logout", - classes=[ - "btn", - "btn-outline-secondary", - "mb-2", - "me-2", - ], - ), - classes=["card-body"], - ), - classes=["card", "mb-4"], - ), + limits = Billing.TIER_LIMITS.get(tier, Billing.TIER_LIMITS["free"]) + + return UI.AccountPage( user=user, - current_page="account", - error=None, + usage=usage, + limits=limits, + portal_url="/billing/portal" if tier == "paid" else None, ) @@ -3164,6 +3024,48 @@ class TestUsageLimits(BaseWebTest): self.assertEqual(usage["articles"], 20) +class TestAccountPage(BaseWebTest): + """Test account page functionality.""" + + def setUp(self) -> None: + """Set up test with user.""" + super().setUp() + self.user_id, _ = Core.Database.create_user( + "test@example.com", + status="active", + ) + self.client.post("/login", data={"email": "test@example.com"}) + + def test_account_page_shows_usage(self) -> None: + """Account page should show usage stats.""" + # Create some usage + ep_id = Core.Database.create_episode( + title="Test Episode", + audio_url="https://example.com/audio.mp3", + duration=300, + content_length=1000, + user_id=self.user_id, + author="Test Author", + original_url="https://example.com/article", + original_url_hash=Core.hash_url("https://example.com/article"), + ) + Core.Database.add_episode_to_user(self.user_id, ep_id) + + response = self.client.get("/account") + + self.assertEqual(response.status_code, 200) + self.assertIn("1 / 10", response.text) # Usage / Limit for free tier + self.assertIn("My Account", response.text) + self.assertIn("test@example.com", response.text) + + def test_account_page_login_required(self) -> None: + """Should redirect to login if not logged in.""" + self.client.get("/logout") + response = self.client.get("/account", follow_redirects=False) + self.assertEqual(response.status_code, 307) + self.assertEqual(response.headers["location"], "/?error=login_required") + + def test() -> None: """Run all tests for the web module.""" Test.run( @@ -3180,6 +3082,7 @@ def test() -> None: TestEpisodeDeduplication, TestMetricsTracking, TestUsageLimits, + TestAccountPage, ], ) -- cgit v1.2.3 From 3d1cee8fd1d8c97e72d0f5365ca0371e5676283e Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 20 Nov 2025 23:27:02 -0500 Subject: feat: implement t-1f9RIzd --- Biz/PodcastItLater/Core.py | 4 ++-- Biz/PodcastItLater/Worker.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py index 8d31956..738531f 100644 --- a/Biz/PodcastItLater/Core.py +++ b/Biz/PodcastItLater/Core.py @@ -373,7 +373,7 @@ class Database: # noqa: PLR0904 SELECT id, url, email, status, created_at, error_message, title, author FROM queue - WHERE status IN ('pending', 'processing', 'error') + WHERE status IN ('pending', 'processing', 'extracting', 'synthesizing', 'uploading', 'error') ORDER BY created_at DESC LIMIT 20 """) @@ -888,7 +888,7 @@ class Database: # noqa: PLR0904 title, author FROM queue WHERE user_id = ? AND - status IN ('pending', 'processing', 'error') + status IN ('pending', 'processing', 'extracting', 'synthesizing', 'uploading', 'error') ORDER BY created_at DESC LIMIT 20 """, diff --git a/Biz/PodcastItLater/Worker.py b/Biz/PodcastItLater/Worker.py index 5203490..94db30e 100644 --- a/Biz/PodcastItLater/Worker.py +++ b/Biz/PodcastItLater/Worker.py @@ -620,6 +620,7 @@ class ArticleProcessor: return # Step 1: Extract article content + Core.Database.update_job_status(job_id, "extracting") title, content, author, pub_date = ( ArticleProcessor.extract_article_content(url) ) @@ -630,6 +631,7 @@ class ArticleProcessor: return # Step 2: Generate audio with metadata + Core.Database.update_job_status(job_id, "synthesizing") audio_data = self.text_to_speech(content, title, author, pub_date) if self.shutdown_handler.is_shutdown_requested(): @@ -638,6 +640,7 @@ class ArticleProcessor: return # Step 3: Upload to S3 + Core.Database.update_job_status(job_id, "uploading") filename = ArticleProcessor.generate_filename(job_id, title) audio_url = self.upload_to_s3(audio_data, filename) -- cgit v1.2.3