summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.tasks/tasks.jsonl6
-rw-r--r--Biz/PodcastItLater/Core.py28
-rw-r--r--Biz/PodcastItLater/Episode.py33
-rw-r--r--Biz/PodcastItLater/Web.py48
-rwxr-xr-xOmni/Ide/typecheck.sh9
5 files changed, 118 insertions, 6 deletions
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl
index 26f27cd..4b29535 100644
--- a/.tasks/tasks.jsonl
+++ b/.tasks/tasks.jsonl
@@ -8,7 +8,7 @@
{"taskCreatedAt":"2025-11-08T20:45:12.764939794Z","taskDependencies":[],"taskId":"t-v2w3x4","taskNamespace":null,"taskParent":null,"taskStatus":"Done","taskTitle":"instruct agents to run 'bild --test' and 'lint' for whatever namespace(s) they are working on after completing a task and fix any reported errors","taskType":"WorkTask","taskUpdatedAt":"2025-11-08T21:25:10.756670871Z"}
{"taskCreatedAt":"2025-11-08T20:48:43.183226361Z","taskDependencies":[],"taskId":"t-y5z6A7","taskNamespace":null,"taskParent":null,"taskStatus":"Open","taskTitle":"The script Omni/Ide/typecheck.sh needs to support Haskell type checking in a similar fashion as how Omni/Ide/repl.sh is able to handle multiple languages","taskType":"WorkTask","taskUpdatedAt":"2025-11-08T20:48:43.183226361Z"}
{"taskCreatedAt":"2025-11-08T21:00:27.020241869Z","taskDependencies":[],"taskId":"t-1ky7gJ2","taskNamespace":null,"taskParent":null,"taskStatus":"Done","taskTitle":"Test shorter IDs","taskType":"WorkTask","taskUpdatedAt":"2025-11-08T21:04:00.990704969Z"}
-{"taskCreatedAt":"2025-11-08T21:00:29.901677247Z","taskDependencies":[],"taskId":"t-1kyjmjN","taskNamespace":null,"taskParent":null,"taskStatus":"Done","taskTitle":"Another test task","taskType":"WorkTask","taskUpdatedAt":"2025-11-08T21:04:04.081664205Z"}
+{"taskCreatedAt":"2025-11-08T21:00:29.901677247Z","taskDependencies":[],"taskId":"t-1kyjmjN","taskNamespace":null,"taskParent":null,"taskStatus":"Done","taskTitle":"Another test task","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T08:13:51.934598506Z"}
{"taskCreatedAt":"2025-11-08T21:11:41.013924674Z","taskDependencies":[],"taskId":"t-1lhJhgS","taskNamespace":null,"taskParent":null,"taskStatus":"Done","taskTitle":"Remove the old aider config in .aider* files and directories. Aider stinks and we will use amp going forward","taskType":"WorkTask","taskUpdatedAt":"2025-11-08T21:28:34.875747622Z"}
{"taskCreatedAt":"2025-11-09T13:05:06.468930038Z","taskDependencies":[],"taskId":"t-PpXWsU","taskNamespace":"Omni/Task.hs","taskParent":null,"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":"t-PpXWsU","taskStatus":"Open","taskTitle":"Implement child ID generation (t-abc123.1)","taskType":"WorkTask","taskUpdatedAt":"2025-11-09T13:05:06.718797697Z"}
@@ -95,8 +95,8 @@
{"taskCreatedAt":"2025-11-16T04:07:07.834181871Z","taskDependencies":[],"taskId":"t-gbu51O","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Done","taskTitle":"Add /public route to display public feed","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:16:43.926763164Z"}
{"taskCreatedAt":"2025-11-16T04:07:08.369657826Z","taskDependencies":[],"taskId":"t-gbwkkw","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Done","taskTitle":"Add /public.rss route for public RSS feed generation","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:16:44.383466957Z"}
{"taskCreatedAt":"2025-11-16T04:07:08.906237761Z","taskDependencies":[],"taskId":"t-gbyzV2","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Done","taskTitle":"Update home page to show public feed when user is logged out","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:16:44.848713835Z"}
-{"taskCreatedAt":"2025-11-16T04:07:09.433392796Z","taskDependencies":[],"taskId":"t-gbAN3x","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Open","taskTitle":"Add admin toggle button to episode cards for public/private status","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:07:09.433392796Z"}
-{"taskCreatedAt":"2025-11-16T04:07:17.092115521Z","taskDependencies":[],"taskId":"t-gc6Vrk","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Open","taskTitle":"Add POST /admin/episode/{id}/toggle-public endpoint","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:07:17.092115521Z"}
+{"taskCreatedAt":"2025-11-16T04:07:09.433392796Z","taskDependencies":[],"taskId":"t-gbAN3x","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Done","taskTitle":"Add admin toggle button to episode cards for public/private status","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T08:13:58.676381973Z"}
+{"taskCreatedAt":"2025-11-16T04:07:17.092115521Z","taskDependencies":[],"taskId":"t-gc6Vrk","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Done","taskTitle":"Add POST /admin/episode/{id}/toggle-public endpoint","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T08:13:58.727479053Z"}
{"taskCreatedAt":"2025-11-16T04:07:17.6266109Z","taskDependencies":[],"taskId":"t-gc9aud","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Open","taskTitle":"Add '+ Add to your feed' button on episode pages for logged-in users","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:07:17.6266109Z"}
{"taskCreatedAt":"2025-11-16T04:07:18.165342861Z","taskDependencies":[],"taskId":"t-gcbqDl","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Open","taskTitle":"Add POST /episode/{id}/add-to-feed endpoint","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:07:18.165342861Z"}
{"taskCreatedAt":"2025-11-16T04:07:18.700573408Z","taskDependencies":[],"taskId":"t-gcdFSb","taskNamespace":"Biz/PodcastItLater.hs","taskParent":"t-ga8V8O","taskStatus":"Open","taskTitle":"Add POST /episode/{id}/track endpoint for metrics tracking","taskType":"WorkTask","taskUpdatedAt":"2025-11-16T04:07:18.700573408Z"}
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index 7b48ac2..c625e11 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -1028,6 +1028,34 @@ class Database: # noqa: PLR0904
return cursor.fetchone() is not None
@staticmethod
+ def track_episode_metric(
+ episode_id: int,
+ event_type: str,
+ user_id: int | None = None,
+ ) -> None:
+ """Track an episode metric event.
+
+ Args:
+ episode_id: ID of the episode
+ event_type: Type of event ('added', 'played', 'downloaded')
+ user_id: Optional user ID (None for anonymous events)
+ """
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT INTO episode_metrics (episode_id, user_id, event_type) "
+ "VALUES (?, ?, ?)",
+ (episode_id, user_id, event_type),
+ )
+ conn.commit()
+ logger.info(
+ "Tracked %s event for episode %d (user: %s)",
+ event_type,
+ episode_id,
+ user_id or "anonymous",
+ )
+
+ @staticmethod
def get_user_episodes(user_id: int) -> list[dict[str, Any]]:
"""Get all episodes in a user's feed."""
with Database.get_connection() as conn:
diff --git a/Biz/PodcastItLater/Episode.py b/Biz/PodcastItLater/Episode.py
index a06b8d9..a516f65 100644
--- a/Biz/PodcastItLater/Episode.py
+++ b/Biz/PodcastItLater/Episode.py
@@ -196,6 +196,7 @@ class EpisodeDetailPageAttrs(Attrs):
creator_email: str | None
user: dict[str, typing.Any] | None
base_url: str
+ user_has_episode: bool
class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]):
@@ -208,6 +209,7 @@ class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]):
creator_email = self.attrs.get("creator_email")
user = self.attrs.get("user")
base_url = self.attrs["base_url"]
+ user_has_episode = self.attrs.get("user_has_episode", False)
share_url = f"{base_url}/episode/{episode_sqid}"
duration_str = UI.format_duration(episode.get("duration"))
@@ -305,6 +307,37 @@ class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]):
),
# Share button
ShareButton(share_url=share_url),
+ # Add to feed button (logged-in users without episode)
+ html.div(
+ html.div(
+ html.div(
+ html.h5(
+ html.i(classes=["bi", "bi-plus-circle", "me-2"]),
+ "Add to Your Feed",
+ classes=["card-title", "mb-3"],
+ ),
+ html.p(
+ "Save this episode to your personal feed "
+ "to listen later.",
+ classes=["text-muted", "mb-3"],
+ ),
+ html.button(
+ html.i(classes=["bi", "bi-plus-lg", "me-1"]),
+ "Add to My Feed",
+ hx_post=f"/episode/{episode['id']}/add-to-feed",
+ hx_target="#add-to-feed-result",
+ hx_swap="innerHTML",
+ classes=["btn", "btn-primary"],
+ ),
+ html.div(id="add-to-feed-result", classes=["mt-2"]),
+ classes=["card-body"],
+ ),
+ classes=["card"],
+ ),
+ classes=["mb-4"],
+ )
+ if user and not user_has_episode
+ else html.div(),
# Back to home link
html.div(
html.a(
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index 97ec439..7c85e0b 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -1454,8 +1454,13 @@ def episode_detail(
# Check if current user is logged in
user_id = request.session.get("user_id")
user = None
+ user_has_episode = False
if user_id:
user = Core.Database.get_user_by_id(user_id)
+ user_has_episode = Core.Database.user_has_episode(
+ user_id,
+ episode_id,
+ )
return Episode.EpisodeDetailPage(
episode=episode,
@@ -1463,6 +1468,7 @@ def episode_detail(
creator_email=creator_email,
user=user,
base_url=BASE_URL,
+ user_has_episode=user_has_episode,
)
except (ValueError, KeyError) as e:
@@ -1611,6 +1617,48 @@ app.post("/admin/episode/{episode_id}/toggle-public")(
)
+@app.post("/episode/{episode_id}/add-to-feed")
+def add_episode_to_feed(request: Request, episode_id: int) -> Response:
+ """Add an episode to the user's feed."""
+ # Check if user is logged in
+ user_id = request.session.get("user_id")
+ if not user_id:
+ return Response(
+ '<div class="alert alert-warning">Please login first</div>',
+ status_code=200,
+ )
+
+ # Check if episode exists
+ episode = Core.Database.get_episode_by_id(episode_id)
+ if not episode:
+ return Response(
+ '<div class="alert alert-danger">Episode not found</div>',
+ status_code=404,
+ )
+
+ # Check if user already has this episode
+ if Core.Database.user_has_episode(user_id, episode_id):
+ return Response(
+ '<div class="alert alert-info">Already in your feed</div>',
+ status_code=200,
+ )
+
+ # Add episode to user's feed
+ Core.Database.add_episode_to_user(user_id, episode_id)
+
+ # Track the "added" event
+ Core.Database.track_episode_metric(episode_id, "added", user_id)
+
+ return Response(
+ '<div class="alert alert-success">'
+ '<i class="bi bi-check-circle me-2"></i>'
+ "Added to your feed! "
+ '<a href="/" class="alert-link">View your feed</a>'
+ "</div>",
+ status_code=200,
+ )
+
+
class BaseWebTest(Test.TestCase):
"""Base class for web tests with database setup."""
diff --git a/Omni/Ide/typecheck.sh b/Omni/Ide/typecheck.sh
index 9de8b54..5f92c90 100755
--- a/Omni/Ide/typecheck.sh
+++ b/Omni/Ide/typecheck.sh
@@ -4,8 +4,8 @@
###
### > typecheck.sh <target..>
###
-### Uses repl.sh to provision the environment for target, then runs the
-### appropriate typechecker for the given module.
+### Runs the typechecker for the given target by building it with bild.
+### This leverages bild's built-in typechecking without running tests.
###
help() {
sed -rn 's/^### ?//;T;p' "$0"
@@ -15,4 +15,7 @@ if [[ $# == 0 ]] || [[ "$1" == "-h" ]]; then
exit 1
fi
target="$1"
-repl.sh --cmd "python -m mypy $target" "$target"
+
+# Use bild to typecheck (bild runs mypy for Python, ghc for Haskell, etc.)
+# This is simpler than trying to set up the environment ourselves
+bild "$target"