diff options
| -rw-r--r-- | .tasks/tasks.jsonl | 6 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Core.py | 28 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Episode.py | 33 | ||||
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 48 | ||||
| -rwxr-xr-x | Omni/Ide/typecheck.sh | 9 |
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" |
