diff options
| -rw-r--r-- | .tasks/tasks.jsonl | 4 | ||||
| -rw-r--r-- | Biz/PodcastItLater/UI.py | 162 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 30 | ||||
| -rw-r--r-- | Omni/Task.hs | 42 | ||||
| -rw-r--r-- | Omni/Task/Core.hs | 43 |
5 files changed, 272 insertions, 9 deletions
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index c6e1c2c..b813b9b 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -13,7 +13,7 @@ {"taskCreatedAt":"2025-11-09T13:05:06.468930038Z","taskDependencies":[],"taskId":"t-PpXWsU","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Task Manager Improvements","taskType":"Epic","taskUpdatedAt":"2025-11-09T13:05:06.468930038Z"} {"taskCreatedAt":"2025-11-09T13:05:06.718797697Z","taskDependencies":[],"taskId":"t-PpYZt2","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Review","taskTitle":"Implement child ID generation (t-abc123.1)","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T00:21:00.331350487Z"} {"taskCreatedAt":"2025-11-09T13:05:06.746734115Z","taskDependencies":[],"taskId":"t-PpZ6JC","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Add child_counters storage","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T13:05:06.746734115Z"} -{"taskCreatedAt":"2025-11-09T13:05:06.774903465Z","taskDependencies":[],"taskId":"t-PpZe3X","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Update createTask to auto-generate child IDs","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T13:05:06.774903465Z"} +{"taskCreatedAt":"2025-11-09T13:05:06.774903465Z","taskDependencies":[],"taskId":"t-PpZe3X","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Update createTask to auto-generate child IDs","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T23:06:53.123460583Z"} {"taskCreatedAt":"2025-11-09T13:05:06.802295008Z","taskDependencies":[],"taskId":"t-PpZlbL","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement task tree visualization command","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T13:47:12.411364105Z"} {"taskCreatedAt":"2025-11-09T13:05:06.829842253Z","taskDependencies":[],"taskId":"t-PpZsm4","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement task stats command","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T19:05:37.772094417Z"} {"taskCreatedAt":"2025-11-09T13:05:06.85771202Z","taskDependencies":[],"taskId":"t-PpZzBA","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Implement epic progress tracking","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T19:19:05.482575703Z"} @@ -144,3 +144,5 @@ {"taskCreatedAt":"2025-11-20T23:17:39.689755832Z","taskDependencies":[],"taskId":"t-1txgI9c","taskNamespace":"Omni/Agent/hs.hs","taskParent":"t-1twEu4W","taskPriority":"P2","taskStatus":"Open","taskTitle":"Implement harvesting logic in Haskell","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T23:17:39.689755832Z"} {"taskCreatedAt":"2025-11-20T23:17:39.708649865Z","taskDependencies":[],"taskId":"t-1txgN3W","taskNamespace":"Omni/Agent/hs.hs","taskParent":"t-1twEu4W","taskPriority":"P2","taskStatus":"Open","taskTitle":"Add integration tests for Agent workflow","taskType":"WorkTask","taskUpdatedAt":"2025-11-20T23:17:39.708649865Z"} {"taskCreatedAt":"2025-11-20T23:51:02.843631362Z","taskDependencies":[],"taskId":"t-1vIPJYG","taskNamespace":"Biz/PodcastItLater.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"PodcastItLater: UX Polish","taskType":"Epic","taskUpdatedAt":"2025-11-20T23:51:02.843631362Z"} +{"taskCreatedAt":"2025-11-21T00:19:08.811498926Z","taskDependencies":[{"depId":"t-PpYZt2","depType":"DiscoveredFrom"}],"taskId":"t-1fKilH","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"bild fails in agent environment due to CODEROOT mismatch","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T00:19:08.811498926Z"} +{"taskCreatedAt":"2025-11-21T00:19:08.829956304Z","taskDependencies":[{"depId":"t-PpYZt2","depType":"DiscoveredFrom"}],"taskId":"t-1fKn9o","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Race condition in generateChildId when concurrent tasks are created","taskType":"WorkTask","taskUpdatedAt":"2025-11-21T00:19:08.829956304Z"} diff --git a/Biz/PodcastItLater/UI.py b/Biz/PodcastItLater/UI.py index da0193c..27f5fff 100644 --- a/Biz/PodcastItLater/UI.py +++ b/Biz/PodcastItLater/UI.py @@ -215,6 +215,24 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): html.i( classes=[ "bi", + "bi-stars", + "me-1", + ], + ), + "Pricing", + href="/pricing", + classes=[ + "nav-link", + "active" if is_active("pricing") else "", + ], + ), + classes=["nav-item"], + ), + html.li( + html.a( + html.i( + classes=[ + "bi", "bi-person-circle", "me-1", ], @@ -387,3 +405,147 @@ class PageLayout(Component[AnyChildren, PageLayoutAttrs]): create_bootstrap_js(), ), ) + + +class PricingPageAttrs(Attrs): + """Attributes for PricingPage component.""" + + user: dict[str, typing.Any] | None + + +class PricingPage(Component[AnyChildren, PricingPageAttrs]): + """Pricing page component.""" + + @override + def render(self) -> PageLayout: + user = self.attrs.get("user") + current_tier = user.get("plan_tier", "free") if user else "free" + + return PageLayout( + user=user, + current_page="pricing", + page_title="Pricing - PodcastItLater", + error=None, + meta_tags=[], + children=[ + html.div( + html.h2("Simple Pricing", classes=["text-center", "mb-5"]), + html.div( + # Free Tier + html.div( + html.div( + html.div( + html.h3("Free", classes=["card-title"]), + html.h4( + "$0", + classes=[ + "card-subtitle", + "mb-3", + "text-muted", + ], + ), + html.p( + "10 articles total", + classes=["card-text"], + ), + html.ul( + html.li("Convert 10 articles"), + html.li("Basic features"), + classes=["list-unstyled", "mb-4"], + ), + html.button( + "Current Plan", + classes=[ + "btn", + "btn-outline-primary", + "w-100", + ], + disabled=True, + ) + if current_tier == "free" + else html.div(), + classes=["card-body"], + ), + classes=["card", "mb-4", "shadow-sm", "h-100"], + ), + classes=["col-md-6"], + ), + # Paid Tier + html.div( + html.div( + html.div( + html.h3( + "Unlimited", + classes=["card-title"], + ), + html.h4( + "$12/mo", + classes=[ + "card-subtitle", + "mb-3", + "text-muted", + ], + ), + html.p( + "Unlimited articles", + classes=["card-text"], + ), + html.ul( + html.li("Unlimited conversions"), + html.li("Priority processing"), + html.li("Support independent software"), + classes=["list-unstyled", "mb-4"], + ), + html.form( + html.button( + "Upgrade Now", + type="submit", + classes=[ + "btn", + "btn-primary", + "w-100", + ], + ), + action="/upgrade", + method="POST", + ) + if user and current_tier == "free" + else ( + html.button( + "Current Plan", + classes=[ + "btn", + "btn-success", + "w-100", + ], + disabled=True, + ) + if user and current_tier == "paid" + else html.a( + "Login to Upgrade", + href="/", + classes=[ + "btn", + "btn-primary", + "w-100", + ], + ) + ), + classes=["card-body"], + ), + classes=[ + "card", + "mb-4", + "shadow-sm", + "border-primary", + "h-100", + ], + ), + classes=["col-md-6"], + ), + classes=["row"], + ), + classes=["container", "py-3"], + ), + ], + ) diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 4a8e57e..7e8e969 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -973,6 +973,36 @@ def public_feed(request: Request) -> PublicFeedPage: ) +@app.get("/pricing") +def pricing(request: Request) -> UI.PricingPage: + """Display pricing page.""" + user_id = request.session.get("user_id") + user = Core.Database.get_user_by_id(user_id) if user_id else None + + return UI.PricingPage( + user=user, + ) + + +@app.post("/upgrade") +def upgrade(request: Request) -> RedirectResponse: + """Start upgrade checkout flow.""" + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse(url="/?error=login_required") + + try: + checkout_url = Billing.create_checkout_session( + user_id, + "paid", + BASE_URL, + ) + return RedirectResponse(url=checkout_url, status_code=303) + except ValueError: + logger.exception("Failed to create checkout session") + return RedirectResponse(url="/pricing?error=checkout_failed") + + def _handle_test_login(email: str, request: Request) -> Response: """Handle login in test mode.""" # Special handling for demo account diff --git a/Omni/Task.hs b/Omni/Task.hs index d1e672a..24e528b 100644 --- a/Omni/Task.hs +++ b/Omni/Task.hs @@ -312,7 +312,47 @@ unitTests = ready <- getReadyTasks -- Both should be ready since Related doesn't block (taskId task1 `elem` map taskId ready) Test.@?= True - (taskId task2 `elem` map taskId ready) Test.@?= True + (taskId task2 `elem` map taskId ready) Test.@?= True, + Test.unit "child task gets sequential ID" <| do + parent <- createTask "Parent" Epic Nothing Nothing P2 [] + child1 <- createTask "Child 1" WorkTask (Just (taskId parent)) Nothing P2 [] + child2 <- createTask "Child 2" WorkTask (Just (taskId parent)) Nothing P2 [] + taskId child1 Test.@?= taskId parent <> ".1" + taskId child2 Test.@?= taskId parent <> ".2", + Test.unit "grandchild task gets sequential ID" <| do + parent <- createTask "Grandparent" Epic Nothing Nothing P2 [] + child <- createTask "Parent" Epic (Just (taskId parent)) Nothing P2 [] + grandchild <- createTask "Grandchild" WorkTask (Just (taskId child)) Nothing P2 [] + taskId grandchild Test.@?= taskId parent <> ".1.1", + Test.unit "siblings of grandchild task get sequential ID" <| do + parent <- createTask "Grandparent" Epic Nothing Nothing P2 [] + child <- createTask "Parent" Epic (Just (taskId parent)) Nothing P2 [] + grandchild1 <- createTask "Grandchild 1" WorkTask (Just (taskId child)) Nothing P2 [] + grandchild2 <- createTask "Grandchild 2" WorkTask (Just (taskId child)) Nothing P2 [] + taskId grandchild1 Test.@?= taskId parent <> ".1.1" + taskId grandchild2 Test.@?= taskId parent <> ".1.2", + Test.unit "child ID generation skips gaps" <| do + parent <- createTask "Parent with gaps" Epic Nothing Nothing P2 [] + child1 <- createTask "Child 1" WorkTask (Just (taskId parent)) Nothing P2 [] + -- Manually create a task with .3 suffix to simulate a gap (or deleted task) + let child3Id = taskId parent <> ".3" + child3 = Task + { taskId = child3Id, + taskTitle = "Child 3", + taskType = WorkTask, + taskParent = Just (taskId parent), + taskNamespace = Nothing, + taskStatus = Open, + taskPriority = P2, + taskDependencies = [], + taskCreatedAt = taskCreatedAt child1, + taskUpdatedAt = taskUpdatedAt child1 + } + saveTask child3 + + -- Create a new child, it should get .4, not .2 + child4 <- createTask "Child 4" WorkTask (Just (taskId parent)) Nothing P2 [] + taskId child4 Test.@?= taskId parent <> ".4" ] -- | Test CLI argument parsing to ensure docopt string matches actual usage diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index f7b7915..98ef6f9 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -13,7 +13,8 @@ import qualified Data.ByteString.Lazy.Char8 as BLC import qualified Data.List as List import qualified Data.Text as T import qualified Data.Text.IO as TIO -import Data.Time (UTCTime, diffTimeToPicoseconds, getCurrentTime, utctDayTime) +import Data.Time (UTCTime, diffTimeToPicoseconds, getCurrentTime, utctDayTime, utctDay) +import Data.Time.Calendar (toModifiedJulianDay) import GHC.Generics () import System.Directory (createDirectoryIfMissing, doesFileExist) import System.Environment (lookupEnv) @@ -104,13 +105,41 @@ initTaskDb = do generateId :: IO Text generateId = do now <- getCurrentTime - -- Convert current time to microseconds since midnight - let dayTime = utctDayTime now - microseconds = diffTimeToPicoseconds dayTime `div` 1000000 - -- Convert to base62 for shorter IDs - encoded = toBase62 (fromIntegral microseconds) + -- Convert current time to microseconds since epoch (using MJD) + let day = utctDay now + dayTime = utctDayTime now + mjd = toModifiedJulianDay day + micros = diffTimeToPicoseconds dayTime `div` 1000000 + -- Combine MJD and micros to ensure uniqueness across days. + -- Multiplier 10^11 (100,000 seconds) is safe for any day length. + totalMicros = (mjd * 100000000000) + micros + encoded = toBase62 totalMicros pure <| "t-" <> T.pack encoded +-- Generate a child ID based on parent ID (e.g. "t-abc.1", "t-abc.1.2") +-- Finds the next available sequential suffix among existing children. +generateChildId :: Text -> IO Text +generateChildId parentId = do + tasks <- loadTasks + -- Find the max suffix among ALL tasks that look like children (to avoid ID collisions) + -- We check all tasks, not just those with taskParent set, because we want to ensure + -- ID uniqueness even if the parent link is missing. + let suffixes = mapMaybe (getSuffix parentId <. taskId) tasks + nextSuffix = case suffixes of + [] -> 1 + s -> maximum s + 1 + pure <| parentId <> "." <> T.pack (show nextSuffix) + +getSuffix :: Text -> Text -> Maybe Int +getSuffix parent childId = + if parent `T.isPrefixOf` childId && T.length childId > T.length parent + then + let rest = T.drop (T.length parent) childId + in if T.head rest == '.' + then readMaybe (T.unpack (T.tail rest)) + else Nothing + else Nothing + -- Convert number to base62 (0-9, a-z, A-Z) toBase62 :: Integer -> String toBase62 0 = "0" @@ -192,7 +221,7 @@ saveTask task = do -- Create a new task createTask :: Text -> TaskType -> Maybe Text -> Maybe Text -> Priority -> [Dependency] -> IO Task createTask title taskType parent namespace priority deps = do - tid <- generateId + tid <- maybe generateId generateChildId parent now <- getCurrentTime let task = Task |
