summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-20 19:25:01 -0500
committerBen Sima <ben@bsima.me>2025-11-20 19:25:01 -0500
commit84251fc367e0d129dd77e951f587e2e2db0e98f6 (patch)
tree4a23c5c246b96b8a99727304165c00433c372d5c
parent0f3d43b40b39c8915303ee19901638197c33f1c6 (diff)
parentada597842025a4dd083dcaf0f278b1123447c760 (diff)
Merge branch 'task/t-PpYZt2' into live
-rw-r--r--.tasks/tasks.jsonl4
-rw-r--r--Biz/PodcastItLater/UI.py162
-rw-r--r--Biz/PodcastItLater/Web.py30
-rw-r--r--Omni/Task.hs42
-rw-r--r--Omni/Task/Core.hs43
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