From 7dfdc7970f2c40dd87921407503fe4fb8fe35e0e Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:12:29 -0500 Subject: [PATCH 01/15] Add support for draft mode --- e2e/reviewDrafts.spec.ts | 41 +++ handlers/reviews.go | 68 +++++ handlers/reviews_drafts.go | 118 ++++++++ handlers/reviews_test.go | 266 ++++++++++++++++++ handlers/routes.go | 3 + handlers/templates/pages/reviews-drafts.html | 54 ++++ handlers/templates/pages/reviews-edit.html | 100 +++++++ handlers/templates/partials/navbar.html | 5 + handlers/views.go | 57 +++- screenjournal/review.go | 1 + store/sqlite/migrations/015-review-drafts.sql | 2 + store/sqlite/reviews.go | 16 +- store/store.go | 7 + 13 files changed, 734 insertions(+), 4 deletions(-) create mode 100644 e2e/reviewDrafts.spec.ts create mode 100644 handlers/reviews_drafts.go create mode 100644 handlers/templates/pages/reviews-drafts.html create mode 100644 store/sqlite/migrations/015-review-drafts.sql diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts new file mode 100644 index 00000000..a17d63c8 --- /dev/null +++ b/e2e/reviewDrafts.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; +import { populateDummyData } from "./helpers/db"; +import { loginAsUserA } from "./helpers/login"; + +test.beforeEach(async ({ page }) => { + await populateDummyData(page); + await loginAsUserA(page); +}); + +test("auto-saves a draft and lets the user resume", async ({ page }) => { + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + await page.getByLabel("When did you watch?").fill("2024-10-10"); + + const draftResponse = page.waitForResponse((response) => + response.url().includes("/reviews/drafts") && + response.request().method() === "POST" && + response.status() === 201 + ); + await page.getByLabel("Other thoughts?").fill("Draft thoughts"); + await draftResponse; + + await page.getByRole("menuitem", { name: "Account" }).click(); + await page.getByRole("menuitem", { name: "Drafts" }).click(); + + await expect(page).toHaveURL("/reviews/drafts"); + + const draftCard = page.getByTestId("draft-card").first(); + await expect(draftCard.getByText("Slow Learners")).toBeVisible(); + await draftCard.getByRole("link", { name: "Continue" }).click(); + + await expect(page.getByLabel("Other thoughts?")).toHaveValue( + "Draft thoughts" + ); + + await page.getByRole("button", { name: "Publish" }).click(); + await expect(page).toHaveURL("/movies/3"); +}); diff --git a/handlers/reviews.go b/handlers/reviews.go index 274515fa..d8254347 100644 --- a/handlers/reviews.go +++ b/handlers/reviews.go @@ -17,12 +17,14 @@ type reviewPostRequest struct { Rating screenjournal.Rating WatchDate screenjournal.WatchDate Blurb screenjournal.Blurb + DraftID *screenjournal.ReviewID } type reviewPutRequest struct { Rating screenjournal.Rating Blurb screenjournal.Blurb Watched screenjournal.WatchDate + Publish bool } func (s Server) reviewsPost() http.HandlerFunc { @@ -34,12 +36,55 @@ func (s Server) reviewsPost() http.HandlerFunc { return } + if req.DraftID != nil { + review, err := s.getDB(r).ReadReview(*req.DraftID) + if err == store.ErrReviewNotFound { + http.Error(w, "Draft not found", http.StatusNotFound) + return + } else if err != nil { + http.Error(w, fmt.Sprintf("Failed to read draft: %v", err), http.StatusInternalServerError) + return + } + + loggedInUsername := mustGetUsernameFromContext(r.Context()) + if !review.Owner.Equal(loggedInUsername) { + http.Error(w, "You can't edit another user's draft", http.StatusForbidden) + return + } + + if !review.IsDraft { + http.Error(w, "Draft already published", http.StatusBadRequest) + return + } + + review.Rating = req.Rating + review.Blurb = req.Blurb + review.Watched = req.WatchDate + review.IsDraft = false + + if err := s.getDB(r).UpdateReview(review); err != nil { + log.Printf("failed to publish draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save review: %v", err), http.StatusInternalServerError) + return + } + + s.announcer.AnnounceNewReview(review) + + if review.MediaType() == screenjournal.MediaTypeMovie { + http.Redirect(w, r, fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()), http.StatusSeeOther) + } else { + http.Redirect(w, r, fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()), http.StatusSeeOther) + } + return + } + review := screenjournal.Review{ Owner: mustGetUsernameFromContext(r.Context()), TvShowSeason: req.TvShowSeason, Rating: req.Rating, Watched: req.WatchDate, Blurb: req.Blurb, + IsDraft: false, Comments: []screenjournal.ReviewComment{}, } @@ -115,6 +160,14 @@ func (s Server) reviewsPut() http.HandlerFunc { review.Rating = parsedRequest.Rating review.Blurb = parsedRequest.Blurb review.Watched = parsedRequest.Watched + wasDraft := review.IsDraft + if parsedRequest.Publish { + if !review.IsDraft { + http.Error(w, "Review already published", http.StatusBadRequest) + return + } + review.IsDraft = false + } if err := s.getDB(r).UpdateReview(review); err != nil { log.Printf("failed to update review: %v", err) @@ -122,6 +175,10 @@ func (s Server) reviewsPut() http.HandlerFunc { return } + if wasDraft && !review.IsDraft { + s.announcer.AnnounceNewReview(review) + } + var newRoute string if review.MediaType() == screenjournal.MediaTypeMovie { newRoute = fmt.Sprintf("/movies/%d", review.Movie.ID.Int64()) @@ -174,6 +231,14 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { parsed := reviewPostRequest{} var err error + if draftRaw := r.PostFormValue("draft-id"); draftRaw != "" { + draftID, err := parse.ReviewIDFromString(draftRaw) + if err != nil { + return reviewPostRequest{}, err + } + parsed.DraftID = &draftID + } + if parsed.MediaType, err = parse.MediaType(r.PostFormValue("media-type")); err != nil { return reviewPostRequest{}, err } @@ -223,6 +288,9 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return reviewPutRequest{}, err } + publishRaw := r.PostFormValue("publish") + parsed.Publish = publishRaw == "true" || publishRaw == "1" + return parsed, nil } diff --git a/handlers/reviews_drafts.go b/handlers/reviews_drafts.go new file mode 100644 index 00000000..8eac075e --- /dev/null +++ b/handlers/reviews_drafts.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/mtlynch/screenjournal/v2/screenjournal" + "github.com/mtlynch/screenjournal/v2/store" +) + +type reviewDraftResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` +} + +func (s Server) reviewsDraftsPost() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := parseReviewPostRequest(r) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) + return + } + + review := screenjournal.Review{ + Owner: mustGetUsernameFromContext(r.Context()), + TvShowSeason: req.TvShowSeason, + Rating: req.Rating, + Watched: req.WatchDate, + Blurb: req.Blurb, + IsDraft: true, + Comments: []screenjournal.ReviewComment{}, + } + + if req.MediaType == screenjournal.MediaTypeMovie { + review.Movie, err = s.moviefromTmdbID(s.getDB(r), req.TmdbID) + if err == store.ErrMovieNotFound { + http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return + } else if err != nil { + log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return + } + } else if req.MediaType == screenjournal.MediaTypeTvShow { + review.TvShow, err = s.tvShowfromTmdbID(s.getDB(r), req.TmdbID) + if err == store.ErrTvShowNotFound { + http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return + } else if err != nil { + log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return + } + } + + review.ID, err = s.getDB(r).InsertReview(review) + if err != nil { + log.Printf("failed to save draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(reviewDraftResponse{ReviewID: review.ID}); err != nil { + log.Printf("failed to encode draft response: %v", err) + } + } +} + +func (s Server) reviewsDraftsPut() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := reviewIDFromRequestPath(r) + if err != nil { + http.Error(w, "Invalid review ID", http.StatusBadRequest) + return + } + + review, err := s.getDB(r).ReadReview(id) + if err == store.ErrReviewNotFound { + http.Error(w, "Draft not found", http.StatusNotFound) + return + } else if err != nil { + http.Error(w, fmt.Sprintf("Failed to read draft: %v", err), http.StatusInternalServerError) + return + } + + loggedInUsername := mustGetUsernameFromContext(r.Context()) + if !review.Owner.Equal(loggedInUsername) { + http.Error(w, "You can't edit another user's draft", http.StatusForbidden) + return + } + + if !review.IsDraft { + http.Error(w, "Review already published", http.StatusBadRequest) + return + } + + parsedRequest, err := parseReviewPutRequest(r) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) + return + } + + review.Rating = parsedRequest.Rating + review.Blurb = parsedRequest.Blurb + review.Watched = parsedRequest.Watched + + if err := s.getDB(r).UpdateReview(review); err != nil { + log.Printf("failed to update draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to update draft: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index 22272062..e911a425 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -2,6 +2,7 @@ package handlers_test import ( "context" + "encoding/json" "errors" "fmt" "log" @@ -913,6 +914,271 @@ func TestReviewsPut(t *testing.T) { } } +func TestReviewsDraftsPost(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + if _, err := dataStore.InsertMovie(movie); err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + { + token: "abc123", + session: sessions.Session{ + Username: screenjournal.Username("userA"), + }, + }, + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(nilAuthenticator, &announcer, &sessionManager, dataStore, NewMockMetadataFinder(nil, nil)) + + payload := "media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-28&blurb=Draft%20thoughts" + req, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusCreated; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var response struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + review, err := dataStore.ReadReview(response.ReviewID) + if err != nil { + t.Fatalf("failed to read draft: %v", err) + } + + if got, want := review.Owner, screenjournal.Username("userA"); got != want { + t.Fatalf("owner=%v, want=%v", got, want) + } + if got, want := review.IsDraft, true; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := review.Blurb, screenjournal.Blurb("Draft thoughts"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := review.Rating, screenjournal.NewRating(5); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } + + if got, want := len(announcer.announcedReviews), 0; got != want { + t.Fatalf("announcedReviews=%d, want=%d", got, want) + } +} + +func TestReviewsDraftsPut(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + movieID, err := dataStore.InsertMovie(movie) + if err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + draft := screenjournal.Review{ + Owner: screenjournal.Username("userA"), + IsDraft: true, + Rating: screenjournal.NewRating(3), + Watched: mustParseWatchDate("2022-10-28"), + Blurb: screenjournal.Blurb("Initial thoughts"), + Movie: screenjournal.Movie{ + ID: movieID, + }, + } + draftID, err := dataStore.InsertReview(draft) + if err != nil { + t.Fatalf("failed to insert draft: %v", err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + { + token: "abc123", + session: sessions.Session{ + Username: screenjournal.Username("userA"), + }, + }, + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(nilAuthenticator, &announcer, &sessionManager, dataStore, NewMockMetadataFinder(nil, nil)) + + payload := "rating=4&watch-date=2022-10-30&blurb=Updated%20draft" + req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/drafts/%d", draftID), strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusNoContent; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + updated, err := dataStore.ReadReview(draftID) + if err != nil { + t.Fatalf("failed to read updated draft: %v", err) + } + + if got, want := updated.IsDraft, true; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := updated.Blurb, screenjournal.Blurb("Updated draft"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := updated.Rating, screenjournal.NewRating(4); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } +} + +func TestReviewsPostPublishesDraft(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + movieID, err := dataStore.InsertMovie(movie) + if err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + draft := screenjournal.Review{ + Owner: screenjournal.Username("userA"), + IsDraft: true, + Rating: screenjournal.NewRating(3), + Watched: mustParseWatchDate("2022-10-28"), + Blurb: screenjournal.Blurb("Initial draft"), + Movie: screenjournal.Movie{ + ID: movieID, + }, + } + draftID, err := dataStore.InsertReview(draft) + if err != nil { + t.Fatalf("failed to insert draft: %v", err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + { + token: "abc123", + session: sessions.Session{ + Username: screenjournal.Username("userA"), + }, + }, + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(nilAuthenticator, &announcer, &sessionManager, dataStore, NewMockMetadataFinder(nil, nil)) + + payload := fmt.Sprintf("draft-id=%d&media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-30&blurb=Final%20thoughts", draftID) + req, err := http.NewRequest("POST", "/reviews", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + rec := httptest.NewRecorder() + s.Router().ServeHTTP(rec, req) + res := rec.Result() + + if got, want := res.StatusCode, http.StatusSeeOther; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + published, err := dataStore.ReadReview(draftID) + if err != nil { + t.Fatalf("failed to read published review: %v", err) + } + + if got, want := published.IsDraft, false; got != want { + t.Fatalf("isDraft=%v, want=%v", got, want) + } + if got, want := published.Blurb, screenjournal.Blurb("Final thoughts"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := published.Rating, screenjournal.NewRating(5); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } + + rr, err := dataStore.ReadReviews() + if err != nil { + t.Fatalf("failed to read reviews: %v", err) + } + if got, want := len(rr), 1; got != want { + t.Fatalf("reviewCount=%d, want=%d", got, want) + } + + if got, want := len(announcer.announcedReviews), 1; got != want { + t.Fatalf("announcedReviews=%d, want=%d", got, want) + } +} + func clearUnpredictableReviewProperties(r *screenjournal.Review) { r.ID = screenjournal.ReviewID(0) r.Created = time.Time{} diff --git a/handlers/routes.go b/handlers/routes.go index 7e90a0d5..6a8cf524 100644 --- a/handlers/routes.go +++ b/handlers/routes.go @@ -57,6 +57,8 @@ func (s *Server) routes() { authenticatedRoutes.HandleFunc("/reviews", s.reviewsPost()).Methods(http.MethodPost) authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsPut()).Methods(http.MethodPut) authenticatedRoutes.HandleFunc("/reviews/{reviewID}", s.reviewsDelete()).Methods(http.MethodDelete) + authenticatedRoutes.HandleFunc("/reviews/drafts", s.reviewsDraftsPost()).Methods(http.MethodPost) + authenticatedRoutes.HandleFunc("/reviews/drafts/{reviewID}", s.reviewsDraftsPut()).Methods(http.MethodPut) authenticatedRoutes.HandleFunc("/reactions", s.reactionsPost()).Methods(http.MethodPost) authenticatedRoutes.HandleFunc("/reactions/{reactionID}", s.reactionsDelete()).Methods(http.MethodDelete) @@ -79,6 +81,7 @@ func (s *Server) routes() { authenticatedViews.HandleFunc("/movies/{movieID}", s.moviesReadGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/tv-shows/{tvShowID}", s.tvShowsReadGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews", s.reviewsGet()).Methods(http.MethodGet) + authenticatedViews.HandleFunc("/reviews/drafts", s.reviewsDraftsGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/by/{username}", s.reviewsGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/new", s.reviewsNewTitleSearchGet()).Methods(http.MethodGet) authenticatedViews.HandleFunc("/reviews/new/tv/pick-season", s.reviewsNewPickSeasonGet()).Methods(http.MethodGet) diff --git a/handlers/templates/pages/reviews-drafts.html b/handlers/templates/pages/reviews-drafts.html new file mode 100644 index 00000000..b92c2e89 --- /dev/null +++ b/handlers/templates/pages/reviews-drafts.html @@ -0,0 +1,54 @@ +{{ define "title" }} + Drafts +{{ end }} + +{{ define "content" }} +

Drafts

+ + {{ if not .Drafts }} +

You don't have any drafts yet.

+ {{ else }} +
+ {{ range .Drafts }} + {{ $media := .Movie }} + {{ $reviewRoute := printf "/reviews/%s/edit" .ID.String }} + {{ if eq .Movie.ID.Int64 0 }} + {{ $media = .TvShow }} + {{ end }} + +
+
+ + Poster for {{ $media.Title }} + +
+
+ + {{ $media.Title }} + {{- if ne .TvShowSeason 0 }} + (Season {{ .TvShowSeason }}) + {{ end -}} + +
+

+ Last saved {{ formatDraftTime .Modified }} +

+ {{ with .Blurb | elideBlurb }} +

+ {{ . }} +

+ {{ end }} + Continue +
+
+
+ {{ end }} +
+ {{ end }} +{{ end }} diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 67fc8d18..6a7e8b75 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -61,11 +61,72 @@ document.body.addEventListener("htmx:afterSwap", function () { addSpoilersRevealButtons(); }); + + const form = document.querySelector("form"); + const blurbEl = document.getElementById("blurb"); + const draftIdInput = document.getElementById("draft-id"); + const autosaveEnabled = form?.dataset.autosave === "true"; + let autosaveTimeout; + let isSavingDraft = false; + let pendingSave = false; + + async function saveDraft() { + if (!autosaveEnabled || !form) { + return; + } + + if (isSavingDraft) { + pendingSave = true; + return; + } + + const draftId = draftIdInput?.value?.trim(); + const url = draftId ? `/reviews/drafts/${draftId}` : "/reviews/drafts"; + + isSavingDraft = true; + try { + const payload = new URLSearchParams(new FormData(form)); + const response = await fetch(url, { + method: draftId ? "PUT" : "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: payload.toString(), + }); + + if (!response.ok) { + return; + } + + if (!draftId) { + const data = await response.json(); + if (data.reviewId && draftIdInput) { + draftIdInput.value = data.reviewId; + } + } + } finally { + isSavingDraft = false; + if (pendingSave) { + pendingSave = false; + scheduleAutosave(); + } + } + } + + function scheduleAutosave() { + window.clearTimeout(autosaveTimeout); + autosaveTimeout = window.setTimeout(saveDraft, 750); + } + + if (autosaveEnabled && blurbEl) { + blurbEl.addEventListener("input", scheduleAutosave); + } {{ end }} {{ define "content" }} {{ $isEditing := ne .Review.ID 0 }} + {{ $isDraft := .Review.IsDraft }} {{ $title := "" }} {{ $releaseYear := 0 }} @@ -91,6 +152,7 @@

Season {{ .Review.TvShowSeason.UInt8 }}

{{ $isEditing := ne .Review.ID 0 }} + {{ $enableAutosave := or (not $isEditing) $isDraft }}
Season {{ .Review.TvShowSeason.UInt8 }} hx-target="body" hx-push-url="true" hx-disabled-elt="input, select, textarea, .btn" + data-autosave="{{ if $enableAutosave }}true{{ else }}false{{ end }}" > + {{ if not $isEditing }} @@ -187,6 +256,36 @@

Season {{ .Review.TvShowSeason.UInt8 }}

{{ if $isEditing }} + {{ if $isDraft }} +
+
+ + Cancel +
+ +
+ {{ else }}
+ {{ end }} {{ else }}
diff --git a/handlers/templates/partials/navbar.html b/handlers/templates/partials/navbar.html index 6065fda8..25ed4b46 100644 --- a/handlers/templates/partials/navbar.html +++ b/handlers/templates/partials/navbar.html @@ -52,6 +52,11 @@ >My ratings +
  • + Drafts +
  • 200 { + return plaintext[:200] + "..." + } + return plaintext + }, + "posterPathToURL": posterPathToURL, + "formatDraftTime": formatIso8601Datetime, + } + + t := template.Must( + template.New("base.html"). + Funcs(fns). + ParseFS( + templatesFS, + append(baseTemplates, "templates/pages/reviews-drafts.html")...)) + + return func(w http.ResponseWriter, r *http.Request) { + loggedInUsername := mustGetUsernameFromContext(r.Context()) + drafts, err := s.getDB(r).ReadReviews( + store.FilterReviewsByUsername(loggedInUsername), + store.FilterReviewsByDraftStatus(true), + ) + if err != nil { + log.Printf("failed to read drafts: %v", err) + http.Error(w, "Failed to read drafts", http.StatusInternalServerError) + return + } + + if err := t.Execute(w, struct { + commonProps + Drafts []screenjournal.Review + }{ + commonProps: makeCommonProps(r.Context()), + Drafts: drafts, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + func (s Server) moviesReadGet() http.HandlerFunc { t := template.Must( template.New("base.html"). @@ -402,7 +448,10 @@ func (s Server) moviesReadGet() http.HandlerFunc { return } - reviews, err := s.getDB(r).ReadReviews(store.FilterReviewsByMovieID(mid)) + reviews, err := s.getDB(r).ReadReviews( + store.FilterReviewsByMovieID(mid), + store.FilterReviewsByDraftStatus(false), + ) if err != nil { log.Printf("failed to read movie reviews: %v", err) http.Error(w, "Failed to retrieve reviews", http.StatusInternalServerError) @@ -501,7 +550,11 @@ func (s Server) tvShowsReadGet() http.HandlerFunc { return } - reviews, err := s.getDB(r).ReadReviews(store.FilterReviewsByTvShowID(tvID), store.FilterReviewsByTvShowSeason(seasonNumber)) + reviews, err := s.getDB(r).ReadReviews( + store.FilterReviewsByTvShowID(tvID), + store.FilterReviewsByTvShowSeason(seasonNumber), + store.FilterReviewsByDraftStatus(false), + ) if err != nil { log.Printf("failed to read TV show reviews: %v", err) http.Error(w, "Failed to retrieve TV show reviews", http.StatusInternalServerError) diff --git a/screenjournal/review.go b/screenjournal/review.go index 0ef1db7f..3caea35f 100644 --- a/screenjournal/review.go +++ b/screenjournal/review.go @@ -20,6 +20,7 @@ type ( Review struct { ID ReviewID Owner Username + IsDraft bool Rating Rating Blurb Blurb Watched WatchDate diff --git a/store/sqlite/migrations/015-review-drafts.sql b/store/sqlite/migrations/015-review-drafts.sql new file mode 100644 index 00000000..c5d5e2e6 --- /dev/null +++ b/store/sqlite/migrations/015-review-drafts.sql @@ -0,0 +1,2 @@ +ALTER TABLE reviews +ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0; diff --git a/store/sqlite/reviews.go b/store/sqlite/reviews.go index 8f88d5f4..45f3e58e 100644 --- a/store/sqlite/reviews.go +++ b/store/sqlite/reviews.go @@ -21,6 +21,7 @@ func (s Store) ReadReview(id screenjournal.ReviewID) (screenjournal.Review, erro movie_id, tv_show_id, tv_show_season, + is_draft, rating, blurb, watched_date, @@ -75,6 +76,10 @@ func (s Store) ReadReviews(opts ...store.ReadReviewsOption) ([]screenjournal.Rev whereClauses = append(whereClauses, "tv_show_season = :tv_show_season") queryArgs = append(queryArgs, sql.Named("tv_show_season", params.Filters.TvShowSeason.UInt8())) } + if params.Filters.IsDraft != nil { + whereClauses = append(whereClauses, "is_draft = :is_draft") + queryArgs = append(queryArgs, sql.Named("is_draft", *params.Filters.IsDraft)) + } query := ` SELECT @@ -83,6 +88,7 @@ func (s Store) ReadReviews(opts ...store.ReadReviewsOption) ([]screenjournal.Rev movie_id, tv_show_id, tv_show_season, + is_draft, rating, blurb, watched_date, @@ -164,6 +170,7 @@ func (s Store) InsertReview(r screenjournal.Review) (screenjournal.ReviewID, err movie_id, tv_show_id, tv_show_season, + is_draft, rating, blurb, watched_date, @@ -171,13 +178,14 @@ func (s Store) InsertReview(r screenjournal.Review) (screenjournal.ReviewID, err last_modified_time ) VALUES ( - :owner, :movie_id, :tv_show_id, :tv_show_season, :rating, :blurb, :watched_date, :created_time, :last_modified_time + :owner, :movie_id, :tv_show_id, :tv_show_season, :is_draft, :rating, :blurb, :watched_date, :created_time, :last_modified_time ) `, sql.Named("owner", r.Owner), sql.Named("movie_id", movieID), sql.Named("tv_show_id", tvShowID), sql.Named("tv_show_season", tvShowSeason), + sql.Named("is_draft", r.IsDraft), sql.Named("rating", r.Rating.Value), sql.Named("blurb", r.Blurb), sql.Named("watched_date", formatWatchDate(r.Watched)), @@ -211,12 +219,14 @@ func (s Store) UpdateReview(r screenjournal.Review) error { if _, err := s.ctx.Exec(` UPDATE reviews SET + is_draft = :is_draft, rating = :rating, blurb = :blurb, watched_date = :watched_date, last_modified_time = :last_modified_time WHERE id = :id`, + sql.Named("is_draft", r.IsDraft), sql.Named("rating", r.Rating.Value), sql.Named("blurb", r.Blurb), sql.Named("watched_date", formatWatchDate(r.Watched)), @@ -259,13 +269,14 @@ func reviewFromRow(row rowScanner) (screenjournal.Review, error) { var movieIDRaw *int var tvShowIDRaw *int var tvShowSeason *int + var isDraftRaw int var ratingRaw *uint8 var blurb string var watchedDateRaw string var createdTimeRaw string var lastModifiedTimeRaw string - err := row.Scan(&id, &owner, &movieIDRaw, &tvShowIDRaw, &tvShowSeason, &ratingRaw, &blurb, &watchedDateRaw, &createdTimeRaw, &lastModifiedTimeRaw) + err := row.Scan(&id, &owner, &movieIDRaw, &tvShowIDRaw, &tvShowSeason, &isDraftRaw, &ratingRaw, &blurb, &watchedDateRaw, &createdTimeRaw, &lastModifiedTimeRaw) if err == sql.ErrNoRows { return screenjournal.Review{}, store.ErrReviewNotFound } else if err != nil { @@ -311,6 +322,7 @@ func reviewFromRow(row rowScanner) (screenjournal.Review, error) { return screenjournal.Review{ ID: screenjournal.ReviewID(id), Owner: screenjournal.Username(owner), + IsDraft: isDraftRaw != 0, Rating: rating, Blurb: screenjournal.Blurb(blurb), Watched: screenjournal.WatchDate(wd), diff --git a/store/store.go b/store/store.go index c1be842a..ee23d712 100644 --- a/store/store.go +++ b/store/store.go @@ -12,6 +12,7 @@ type ( MovieID *screenjournal.MovieID TvShowID *screenjournal.TvShowID TvShowSeason *screenjournal.TvShowSeason + IsDraft *bool } ReadReviewsParams struct { @@ -57,6 +58,12 @@ func FilterReviewsByTvShowSeason(season screenjournal.TvShowSeason) func(*ReadRe } } +func FilterReviewsByDraftStatus(isDraft bool) func(*ReadReviewsParams) { + return func(p *ReadReviewsParams) { + p.Filters.IsDraft = &isDraft + } +} + func SortReviews(order screenjournal.SortOrder) func(*ReadReviewsParams) { return func(p *ReadReviewsParams) { p.Order = &order From bbc929b57c8f5b87b396b66b1c6ed4eec6fe52aa Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:16:38 -0500 Subject: [PATCH 02/15] Add indicators we're saving the draft --- handlers/templates/pages/reviews-edit.html | 50 +++++++++++++++++++++- handlers/views.go | 1 + 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 6a7e8b75..c8066f70 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -65,11 +65,16 @@ const form = document.querySelector("form"); const blurbEl = document.getElementById("blurb"); const draftIdInput = document.getElementById("draft-id"); + const draftStatusEl = document.getElementById("draft-status"); const autosaveEnabled = form?.dataset.autosave === "true"; let autosaveTimeout; let isSavingDraft = false; let pendingSave = false; + function formatDraftTimestamp(ts) { + return ts.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + async function saveDraft() { if (!autosaveEnabled || !form) { return; @@ -84,6 +89,9 @@ const url = draftId ? `/reviews/drafts/${draftId}` : "/reviews/drafts"; isSavingDraft = true; + if (draftStatusEl) { + draftStatusEl.textContent = "Saving..."; + } try { const payload = new URLSearchParams(new FormData(form)); const response = await fetch(url, { @@ -104,6 +112,12 @@ draftIdInput.value = data.reviewId; } } + + if (draftStatusEl) { + draftStatusEl.textContent = `Draft saved at ${formatDraftTimestamp( + new Date() + )}`; + } } finally { isSavingDraft = false; if (pendingSave) { @@ -225,6 +239,18 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ .Review.Blurb }}
  • + {{ if $enableAutosave }} +
    + {{ if and $isDraft (not (.Review.Modified.IsZero)) }} + Last saved {{ .Review.Modified | formatDraftTime }} + {{ end }} +
    + {{ end }}

    formatting options Season {{ .Review.TvShowSeason.UInt8 }} {{ end }} {{ else }}

    {{ end }} diff --git a/handlers/views.go b/handlers/views.go index b1dcfd9f..efe9b86a 100644 --- a/handlers/views.go +++ b/handlers/views.go @@ -164,6 +164,7 @@ var reviewPageFns = template.FuncMap{ "formatDate": func(t time.Time) string { return t.Format(time.DateOnly) }, + "formatDraftTime": formatIso8601Datetime, } func (s Server) indexGet() http.HandlerFunc { From 20f4779628bef4482b7ddc59e379aeb03f04aa10 Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:22:17 -0500 Subject: [PATCH 03/15] work in progress --- handlers/reviews_drafts.go | 7 ++ handlers/reviews_test.go | 7 +- handlers/templates/pages/reviews-drafts.html | 4 +- handlers/templates/pages/reviews-edit.html | 103 ++++++++++++++----- handlers/views.go | 38 ++++++- 5 files changed, 127 insertions(+), 32 deletions(-) diff --git a/handlers/reviews_drafts.go b/handlers/reviews_drafts.go index 8eac075e..d10a2cee 100644 --- a/handlers/reviews_drafts.go +++ b/handlers/reviews_drafts.go @@ -21,6 +21,8 @@ func (s Server) reviewsDraftsPost() http.HandlerFunc { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) return } + saveDraftIntent := r.PostFormValue("save-draft") == "true" || + r.PostFormValue("save-draft") == "1" review := screenjournal.Review{ Owner: mustGetUsernameFromContext(r.Context()), @@ -61,6 +63,11 @@ func (s Server) reviewsDraftsPost() http.HandlerFunc { return } + if saveDraftIntent { + http.Redirect(w, r, "/reviews/drafts", http.StatusSeeOther) + return + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(reviewDraftResponse{ReviewID: review.ID}); err != nil { diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index e911a425..4bfcf52c 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "net/http/httptest" + "net/url" "reflect" "strings" "testing" @@ -1132,7 +1133,11 @@ func TestReviewsPostPublishesDraft(t *testing.T) { sessionManager := newMockSessionManager(sessions) s := handlers.New(nilAuthenticator, &announcer, &sessionManager, dataStore, NewMockMetadataFinder(nil, nil)) - payload := fmt.Sprintf("draft-id=%d&media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-30&blurb=Final%20thoughts", draftID) + payload := fmt.Sprintf( + "draft-id=%d&media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-30&blurb=%s", + draftID, + url.QueryEscape("Final thoughts"), + ) req, err := http.NewRequest("POST", "/reviews", strings.NewReader(payload)) if err != nil { t.Fatal(err) diff --git a/handlers/templates/pages/reviews-drafts.html b/handlers/templates/pages/reviews-drafts.html index b92c2e89..caa6d6c5 100644 --- a/handlers/templates/pages/reviews-drafts.html +++ b/handlers/templates/pages/reviews-drafts.html @@ -3,8 +3,6 @@ {{ end }} {{ define "content" }} -

    Drafts

    - {{ if not .Drafts }}

    You don't have any drafts yet.

    {{ else }} @@ -35,7 +33,7 @@

    - Last saved {{ formatDraftTime .Modified }} + Last saved {{ relativeDraftDate .Modified }}

    {{ with .Blurb | elideBlurb }}

    diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index c8066f70..62941e09 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -67,12 +67,48 @@ const draftIdInput = document.getElementById("draft-id"); const draftStatusEl = document.getElementById("draft-status"); const autosaveEnabled = form?.dataset.autosave === "true"; + let lastSavedAt = null; + let statusInterval; let autosaveTimeout; let isSavingDraft = false; let pendingSave = false; - function formatDraftTimestamp(ts) { - return ts.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + function formatDraftAge() { + if (!lastSavedAt) { + return ""; + } + + const secondsAgo = Math.max( + 1, + Math.floor((Date.now() - lastSavedAt.getTime()) / 1000) + ); + + if (secondsAgo < 60) { + return `Draft saved ${secondsAgo} second${secondsAgo === 1 ? "" : "s"} ago`; + } + + const minutesAgo = Math.floor(secondsAgo / 60); + if (minutesAgo < 60) { + return `Draft saved ${minutesAgo} minute${minutesAgo === 1 ? "" : "s"} ago`; + } + + const hoursAgo = Math.floor(minutesAgo / 60); + if (hoursAgo < 24) { + return `Draft saved ${hoursAgo} hour${hoursAgo === 1 ? "" : "s"} ago`; + } + + const daysAgo = Math.floor(hoursAgo / 24); + return `Draft saved ${daysAgo} day${daysAgo === 1 ? "" : "s"} ago`; + } + + function renderDraftStatus() { + if (!draftStatusEl) { + return; + } + const message = formatDraftAge(); + if (message) { + draftStatusEl.textContent = message; + } } async function saveDraft() { @@ -89,9 +125,6 @@ const url = draftId ? `/reviews/drafts/${draftId}` : "/reviews/drafts"; isSavingDraft = true; - if (draftStatusEl) { - draftStatusEl.textContent = "Saving..."; - } try { const payload = new URLSearchParams(new FormData(form)); const response = await fetch(url, { @@ -114,9 +147,8 @@ } if (draftStatusEl) { - draftStatusEl.textContent = `Draft saved at ${formatDraftTimestamp( - new Date() - )}`; + lastSavedAt = new Date(); + renderDraftStatus(); } } finally { isSavingDraft = false; @@ -135,6 +167,17 @@ if (autosaveEnabled && blurbEl) { blurbEl.addEventListener("input", scheduleAutosave); } + if (autosaveEnabled && draftStatusEl?.dataset.lastSavedAt) { + const epochSeconds = Number.parseInt( + draftStatusEl.dataset.lastSavedAt, + 10 + ); + if (epochSeconds > 0) { + lastSavedAt = new Date(epochSeconds * 1000); + renderDraftStatus(); + statusInterval = window.setInterval(renderDraftStatus, 10000); + } + } {{ end }} @@ -157,7 +200,12 @@ {{ end }} -

    {{ $title }} ({{ $releaseYear }})

    +

    + {{ $title }} ({{ $releaseYear }}) + {{ if $isDraft }} + Draft + {{ end }} +

    {{ if ne .Review.TvShowSeason.UInt8 0 }}

    Season {{ .Review.TvShowSeason.UInt8 }}

    @@ -242,12 +290,13 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ if $enableAutosave }}
    {{ if and $isDraft (not (.Review.Modified.IsZero)) }} - Last saved {{ .Review.Modified | formatDraftTime }} + Draft saved {{ .Review.Modified | relativeDraftDate }} {{ end }}
    {{ end }} @@ -336,28 +385,32 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ end }} {{ else }}
    -
    - - - +
    {{ end }} diff --git a/handlers/views.go b/handlers/views.go index efe9b86a..7f4d51ae 100644 --- a/handlers/views.go +++ b/handlers/views.go @@ -164,7 +164,7 @@ var reviewPageFns = template.FuncMap{ "formatDate": func(t time.Time) string { return t.Format(time.DateOnly) }, - "formatDraftTime": formatIso8601Datetime, + "relativeDraftDate": relativeDraftDate, } func (s Server) indexGet() http.HandlerFunc { @@ -389,8 +389,8 @@ func (s Server) reviewsDraftsGet() http.HandlerFunc { } return plaintext }, - "posterPathToURL": posterPathToURL, - "formatDraftTime": formatIso8601Datetime, + "posterPathToURL": posterPathToURL, + "relativeDraftDate": relativeDraftDate, } t := template.Must( @@ -1237,6 +1237,38 @@ func relativeCommentDate(t time.Time) string { return fmt.Sprintf("%d years ago", yearsAgo) } +func relativeDraftDate(t time.Time) string { + secondsAgo := int(time.Since(t).Seconds()) + if secondsAgo < 60 { + if secondsAgo <= 1 { + return "1 second ago" + } + return fmt.Sprintf("%d seconds ago", secondsAgo) + } + + minutesAgo := int(time.Since(t).Minutes()) + if minutesAgo == 1 { + return "1 minute ago" + } + if minutesAgo < 60 { + return fmt.Sprintf("%d minutes ago", minutesAgo) + } + + hoursAgo := int(time.Since(t).Hours()) + if hoursAgo == 1 { + return "1 hour ago" + } + if hoursAgo < 24 { + return fmt.Sprintf("%d hours ago", hoursAgo) + } + + daysAgo := int(time.Since(t).Hours() / 24) + if daysAgo == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", daysAgo) +} + func formatIso8601Datetime(t time.Time) string { return t.Format("2006-01-02 3:04 pm") } From f935e4c09e7c04fc7b796359f13abfbb7863b1b1 Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:26:39 -0500 Subject: [PATCH 04/15] Prevent dupe reviews --- e2e/reviewDrafts.spec.ts | 9 +- handlers/reviews_drafts.go | 55 ++++++++- handlers/reviews_test.go | 111 +++++++++++++++++++ handlers/templates/pages/reviews-drafts.html | 8 +- handlers/templates/pages/reviews-edit.html | 87 ++++++++------- 5 files changed, 218 insertions(+), 52 deletions(-) diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts index a17d63c8..b00af2fe 100644 --- a/e2e/reviewDrafts.spec.ts +++ b/e2e/reviewDrafts.spec.ts @@ -15,10 +15,11 @@ test("auto-saves a draft and lets the user resume", async ({ page }) => { await page.getByLabel("When did you watch?").fill("2024-10-10"); - const draftResponse = page.waitForResponse((response) => - response.url().includes("/reviews/drafts") && - response.request().method() === "POST" && - response.status() === 201 + const draftResponse = page.waitForResponse( + (response) => + response.url().includes("/reviews/drafts") && + response.request().method() === "POST" && + response.status() === 201 ); await page.getByLabel("Other thoughts?").fill("Draft thoughts"); await draftResponse; diff --git a/handlers/reviews_drafts.go b/handlers/reviews_drafts.go index d10a2cee..0a0ca836 100644 --- a/handlers/reviews_drafts.go +++ b/handlers/reviews_drafts.go @@ -24,8 +24,9 @@ func (s Server) reviewsDraftsPost() http.HandlerFunc { saveDraftIntent := r.PostFormValue("save-draft") == "true" || r.PostFormValue("save-draft") == "1" + loggedInUsername := mustGetUsernameFromContext(r.Context()) review := screenjournal.Review{ - Owner: mustGetUsernameFromContext(r.Context()), + Owner: loggedInUsername, TvShowSeason: req.TvShowSeason, Rating: req.Rating, Watched: req.WatchDate, @@ -56,12 +57,29 @@ func (s Server) reviewsDraftsPost() http.HandlerFunc { } } - review.ID, err = s.getDB(r).InsertReview(review) + existingDraft, err := s.findExistingDraft(r, loggedInUsername, review) if err != nil { - log.Printf("failed to save draft: %v", err) + log.Printf("failed to find existing draft: %v", err) http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) return } + statusCode := http.StatusCreated + if existingDraft != nil { + review.ID = existingDraft.ID + if err := s.getDB(r).UpdateReview(review); err != nil { + log.Printf("failed to update draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + statusCode = http.StatusOK + } else { + review.ID, err = s.getDB(r).InsertReview(review) + if err != nil { + log.Printf("failed to save draft: %v", err) + http.Error(w, fmt.Sprintf("Failed to save draft: %v", err), http.StatusInternalServerError) + return + } + } if saveDraftIntent { http.Redirect(w, r, "/reviews/drafts", http.StatusSeeOther) @@ -69,13 +87,42 @@ func (s Server) reviewsDraftsPost() http.HandlerFunc { } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(reviewDraftResponse{ReviewID: review.ID}); err != nil { log.Printf("failed to encode draft response: %v", err) } } } +func (s Server) findExistingDraft(r *http.Request, owner screenjournal.Username, draft screenjournal.Review) (*screenjournal.Review, error) { + queryOptions := []store.ReadReviewsOption{ + store.FilterReviewsByUsername(owner), + store.FilterReviewsByDraftStatus(true), + } + if draft.MediaType() == screenjournal.MediaTypeMovie { + queryOptions = append(queryOptions, store.FilterReviewsByMovieID(draft.Movie.ID)) + } else { + queryOptions = append(queryOptions, store.FilterReviewsByTvShowID(draft.TvShow.ID)) + queryOptions = append(queryOptions, store.FilterReviewsByTvShowSeason(draft.TvShowSeason)) + } + + drafts, err := s.getDB(r).ReadReviews(queryOptions...) + if err != nil { + return nil, err + } + if len(drafts) == 0 { + return nil, nil + } + + latest := drafts[0] + for _, candidate := range drafts[1:] { + if candidate.Modified.After(latest.Modified) { + latest = candidate + } + } + return &latest, nil +} + func (s Server) reviewsDraftsPut() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := reviewIDFromRequestPath(r) diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index 4bfcf52c..6588dd37 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -23,6 +23,7 @@ import ( "github.com/mtlynch/screenjournal/v2/metadata" "github.com/mtlynch/screenjournal/v2/random" "github.com/mtlynch/screenjournal/v2/screenjournal" + "github.com/mtlynch/screenjournal/v2/store" "github.com/mtlynch/screenjournal/v2/store/test_sqlite" ) @@ -998,6 +999,116 @@ func TestReviewsDraftsPost(t *testing.T) { } } +func TestReviewsDraftsPostReusesExistingDraft(t *testing.T) { + dataStore := test_sqlite.New() + + mockUser := screenjournal.User{ + Username: screenjournal.Username("userA"), + Email: screenjournal.Email("userA@example.com"), + PasswordHash: screenjournal.PasswordHash("dummy-password-hash"), + } + if err := dataStore.InsertUser(mockUser); err != nil { + t.Fatalf("failed to insert mock user: %+v: %v", mockUser, err) + } + + movie := screenjournal.Movie{ + TmdbID: screenjournal.TmdbID(38), + ImdbID: screenjournal.ImdbID("tt0338013"), + Title: screenjournal.MediaTitle("Eternal Sunshine of the Spotless Mind"), + ReleaseDate: screenjournal.ReleaseDate(mustParseDate("2004-03-19")), + } + if _, err := dataStore.InsertMovie(movie); err != nil { + t.Fatalf("failed to insert mock movie: %+v: %v", movie, err) + } + + announcer := mockAnnouncer{} + sessions := []mockSessionEntry{ + { + token: "abc123", + session: sessions.Session{ + Username: screenjournal.Username("userA"), + }, + }, + } + sessionManager := newMockSessionManager(sessions) + s := handlers.New(nilAuthenticator, &announcer, &sessionManager, dataStore, NewMockMetadataFinder(nil, nil)) + + firstPayload := "media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-28&blurb=Draft%20thoughts" + firstReq, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(firstPayload)) + if err != nil { + t.Fatal(err) + } + firstReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + firstReq.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + firstRec := httptest.NewRecorder() + s.Router().ServeHTTP(firstRec, firstReq) + firstRes := firstRec.Result() + + if got, want := firstRes.StatusCode, http.StatusCreated; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var firstResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(firstRes.Body).Decode(&firstResponse); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + secondPayload := "media-type=movie&tmdb-id=38&rating=4&watch-date=2022-10-29&blurb=Updated%20draft" + secondReq, err := http.NewRequest("POST", "/reviews/drafts", strings.NewReader(secondPayload)) + if err != nil { + t.Fatal(err) + } + secondReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + secondReq.AddCookie(&http.Cookie{ + Name: mockSessionTokenName, + Value: "abc123", + }) + + secondRec := httptest.NewRecorder() + s.Router().ServeHTTP(secondRec, secondReq) + secondRes := secondRec.Result() + + if got, want := secondRes.StatusCode, http.StatusOK; got != want { + t.Fatalf("httpStatus=%v, want=%v", got, want) + } + + var secondResponse struct { + ReviewID screenjournal.ReviewID `json:"reviewId"` + } + if err := json.NewDecoder(secondRes.Body).Decode(&secondResponse); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if got, want := secondResponse.ReviewID, firstResponse.ReviewID; got != want { + t.Fatalf("reviewID=%v, want=%v", got, want) + } + + drafts, err := dataStore.ReadReviews( + store.FilterReviewsByUsername(screenjournal.Username("userA")), + store.FilterReviewsByMovieID(screenjournal.MovieID(1)), + store.FilterReviewsByDraftStatus(true), + ) + if err != nil { + t.Fatalf("failed to read drafts: %v", err) + } + if got, want := len(drafts), 1; got != want { + t.Fatalf("draftCount=%d, want=%d", got, want) + } + + if got, want := drafts[0].Blurb, screenjournal.Blurb("Updated draft"); got != want { + t.Fatalf("blurb=%v, want=%v", got, want) + } + if got, want := drafts[0].Rating, screenjournal.NewRating(4); !got.Equal(want) { + t.Fatalf("rating=%v, want=%v", got, want) + } +} + func TestReviewsDraftsPut(t *testing.T) { dataStore := test_sqlite.New() diff --git a/handlers/templates/pages/reviews-drafts.html b/handlers/templates/pages/reviews-drafts.html index caa6d6c5..b8273b22 100644 --- a/handlers/templates/pages/reviews-drafts.html +++ b/handlers/templates/pages/reviews-drafts.html @@ -14,6 +14,7 @@ {{ $media = .TvShow }} {{ end }} +
    @@ -33,16 +34,15 @@

    - Last saved {{ relativeDraftDate .Modified }} + Last saved + {{ relativeDraftDate .Modified }}

    {{ with .Blurb | elideBlurb }}

    {{ . }}

    {{ end }} - Continue + Continue
    diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 62941e09..d4d9f53d 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -84,12 +84,16 @@ ); if (secondsAgo < 60) { - return `Draft saved ${secondsAgo} second${secondsAgo === 1 ? "" : "s"} ago`; + return `Draft saved ${secondsAgo} second${ + secondsAgo === 1 ? "" : "s" + } ago`; } const minutesAgo = Math.floor(secondsAgo / 60); if (minutesAgo < 60) { - return `Draft saved ${minutesAgo} minute${minutesAgo === 1 ? "" : "s"} ago`; + return `Draft saved ${minutesAgo} minute${ + minutesAgo === 1 ? "" : "s" + } ago`; } const hoursAgo = Math.floor(minutesAgo / 60); @@ -227,7 +231,11 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    hx-target="body" hx-push-url="true" hx-disabled-elt="input, select, textarea, .btn" - data-autosave="{{ if $enableAutosave }}true{{ else }}false{{ end }}" + data-autosave="{{ if $enableAutosave }} + true + {{ else }} + false + {{ end }}" > Season {{ .Review.TvShowSeason.UInt8 }} class="text-muted text-end" aria-live="polite" data-testid="draft-status" - data-last-saved-at="{{ if $isDraft }}{{ .Review.Modified.Unix }}{{ end }}" + data-last-saved-at="{{ if $isDraft }} + {{ .Review.Modified.Unix }} + {{ end }}" > {{ if and $isDraft (not (.Review.Modified.IsZero)) }} - Draft saved {{ .Review.Modified | relativeDraftDate }} + Draft saved + {{ .Review.Modified | relativeDraftDate }} {{ end }} {{ end }} @@ -334,11 +345,7 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ if $isDraft }}
    - @@ -361,27 +368,27 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ else }} -
    -
    - - Cancel -
    -
    - - Delete - +
    +
    + + Cancel +
    +
    -
    {{ end }} {{ else }}
    @@ -398,16 +405,16 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    From 4143269deee768e74aaa1cf43f593904dd31cb24 Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:33:00 -0500 Subject: [PATCH 05/15] Don't overwrite drafts --- e2e/reviewDrafts.spec.ts | 29 ++++++++++ handlers/templates/pages/reviews-edit.html | 63 ++++++++++++++-------- handlers/views.go | 29 ++++++++++ 3 files changed, 99 insertions(+), 22 deletions(-) diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts index b00af2fe..c9a97d35 100644 --- a/e2e/reviewDrafts.spec.ts +++ b/e2e/reviewDrafts.spec.ts @@ -40,3 +40,32 @@ test("auto-saves a draft and lets the user resume", async ({ page }) => { await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); }); + +test("redirects to an existing draft when starting the same review", async ({ + page, +}) => { + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + await page.getByLabel("When did you watch?").fill("2024-10-10"); + + const draftResponse = page.waitForResponse( + (response) => + response.url().includes("/reviews/drafts") && + response.request().method() === "POST" && + response.status() === 201 + ); + await page.getByLabel("Other thoughts?").fill("Second draft"); + await draftResponse; + + await page.getByRole("menuitem", { name: "Home" }).click(); + await page.getByRole("button", { name: "Add Rating" }).click(); + + await page.getByPlaceholder("Search").pressSequentially("slow lear"); + await page.getByText("Slow Learners (2015)").click(); + + await expect(page).toHaveURL(/\/reviews\/\d+\/edit/); + await expect(page.getByText("Draft")).toBeVisible(); +}); diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index d4d9f53d..66d6c0c9 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -63,7 +63,6 @@ }); const form = document.querySelector("form"); - const blurbEl = document.getElementById("blurb"); const draftIdInput = document.getElementById("draft-id"); const draftStatusEl = document.getElementById("draft-status"); const autosaveEnabled = form?.dataset.autosave === "true"; @@ -112,9 +111,20 @@ const message = formatDraftAge(); if (message) { draftStatusEl.textContent = message; + draftStatusEl.classList.remove("text-danger"); + draftStatusEl.classList.add("text-muted"); } } + function showDraftError(message) { + if (!draftStatusEl) { + return; + } + draftStatusEl.textContent = message; + draftStatusEl.classList.add("text-danger"); + draftStatusEl.classList.remove("text-muted"); + } + async function saveDraft() { if (!autosaveEnabled || !form) { return; @@ -140,6 +150,7 @@ }); if (!response.ok) { + showDraftError("Failed to save draft."); return; } @@ -153,7 +164,12 @@ if (draftStatusEl) { lastSavedAt = new Date(); renderDraftStatus(); + if (!statusInterval) { + statusInterval = window.setInterval(renderDraftStatus, 10000); + } } + } catch (err) { + showDraftError("Failed to save draft."); } finally { isSavingDraft = false; if (pendingSave) { @@ -168,9 +184,6 @@ autosaveTimeout = window.setTimeout(saveDraft, 750); } - if (autosaveEnabled && blurbEl) { - blurbEl.addEventListener("input", scheduleAutosave); - } if (autosaveEnabled && draftStatusEl?.dataset.lastSavedAt) { const epochSeconds = Number.parseInt( draftStatusEl.dataset.lastSavedAt, @@ -182,6 +195,19 @@ statusInterval = window.setInterval(renderDraftStatus, 10000); } } + if (autosaveEnabled && form) { + const handleAutoSaveEvent = (evt) => { + const target = evt.target; + if (!target || target.closest("button")) { + return; + } + if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) { + scheduleAutosave(); + } + }; + form.addEventListener("input", handleAutoSaveEvent); + form.addEventListener("change", handleAutoSaveEvent); + } {{ end }} @@ -295,24 +321,17 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ .Review.Blurb }}
    - {{ if $enableAutosave }} -
    - {{ if and $isDraft (not (.Review.Modified.IsZero)) }} - Draft saved - {{ .Review.Modified | relativeDraftDate }} - {{ end }} -
    - {{ end }} -
    -

    +

    + {{ if $enableAutosave }} +
    + {{ end }} +

    formatting options  Date: Fri, 9 Jan 2026 09:38:06 -0500 Subject: [PATCH 06/15] work in progress --- handlers/templates/pages/reviews-edit.html | 98 +++++++++------------- 1 file changed, 41 insertions(+), 57 deletions(-) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 66d6c0c9..698e2666 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -66,54 +66,24 @@ const draftIdInput = document.getElementById("draft-id"); const draftStatusEl = document.getElementById("draft-status"); const autosaveEnabled = form?.dataset.autosave === "true"; - let lastSavedAt = null; - let statusInterval; + let hasSavedDraft = false; let autosaveTimeout; let isSavingDraft = false; let pendingSave = false; - function formatDraftAge() { - if (!lastSavedAt) { - return ""; - } - - const secondsAgo = Math.max( - 1, - Math.floor((Date.now() - lastSavedAt.getTime()) / 1000) - ); - - if (secondsAgo < 60) { - return `Draft saved ${secondsAgo} second${ - secondsAgo === 1 ? "" : "s" - } ago`; - } - - const minutesAgo = Math.floor(secondsAgo / 60); - if (minutesAgo < 60) { - return `Draft saved ${minutesAgo} minute${ - minutesAgo === 1 ? "" : "s" - } ago`; - } - - const hoursAgo = Math.floor(minutesAgo / 60); - if (hoursAgo < 24) { - return `Draft saved ${hoursAgo} hour${hoursAgo === 1 ? "" : "s"} ago`; - } - - const daysAgo = Math.floor(hoursAgo / 24); - return `Draft saved ${daysAgo} day${daysAgo === 1 ? "" : "s"} ago`; - } - function renderDraftStatus() { if (!draftStatusEl) { return; } - const message = formatDraftAge(); - if (message) { - draftStatusEl.textContent = message; + if (hasSavedDraft) { + draftStatusEl.textContent = "Saved draft"; draftStatusEl.classList.remove("text-danger"); draftStatusEl.classList.add("text-muted"); + return; } + draftStatusEl.textContent = "Draft will auto-save."; + draftStatusEl.classList.remove("text-danger"); + draftStatusEl.classList.add("text-muted"); } function showDraftError(message) { @@ -139,6 +109,11 @@ const url = draftId ? `/reviews/drafts/${draftId}` : "/reviews/drafts"; isSavingDraft = true; + if (!lastSavedAt && draftStatusEl) { + draftStatusEl.textContent = "Saving draft..."; + draftStatusEl.classList.remove("text-danger"); + draftStatusEl.classList.add("text-muted"); + } try { const payload = new URLSearchParams(new FormData(form)); const response = await fetch(url, { @@ -162,11 +137,8 @@ } if (draftStatusEl) { - lastSavedAt = new Date(); + hasSavedDraft = true; renderDraftStatus(); - if (!statusInterval) { - statusInterval = window.setInterval(renderDraftStatus, 10000); - } } } catch (err) { showDraftError("Failed to save draft."); @@ -184,17 +156,6 @@ autosaveTimeout = window.setTimeout(saveDraft, 750); } - if (autosaveEnabled && draftStatusEl?.dataset.lastSavedAt) { - const epochSeconds = Number.parseInt( - draftStatusEl.dataset.lastSavedAt, - 10 - ); - if (epochSeconds > 0) { - lastSavedAt = new Date(epochSeconds * 1000); - renderDraftStatus(); - statusInterval = window.setInterval(renderDraftStatus, 10000); - } - } if (autosaveEnabled && form) { const handleAutoSaveEvent = (evt) => { const target = evt.target; @@ -208,6 +169,12 @@ form.addEventListener("input", handleAutoSaveEvent); form.addEventListener("change", handleAutoSaveEvent); } + if (autosaveEnabled) { + if (draftStatusEl?.dataset.hadSavedDraft === "true") { + hasSavedDraft = true; + } + renderDraftStatus(); + } {{ end }} @@ -328,7 +295,7 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    class="text-muted" aria-live="polite" data-testid="draft-status" - data-last-saved-at="{{ if $isDraft }}{{ .Review.Modified.Unix }}{{ end }}" + data-had-saved-draft="{{ if $isDraft }}true{{ end }}" >
    {{ end }}

    @@ -364,10 +331,27 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ if $isDraft }}
    - +
    + + + +
    Date: Fri, 9 Jan 2026 09:39:58 -0500 Subject: [PATCH 07/15] Fix autosave --- handlers/templates/pages/reviews-edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 698e2666..862837cc 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -65,7 +65,7 @@ const form = document.querySelector("form"); const draftIdInput = document.getElementById("draft-id"); const draftStatusEl = document.getElementById("draft-status"); - const autosaveEnabled = form?.dataset.autosave === "true"; + const autosaveEnabled = form?.dataset.autosave?.trim() === "true"; let hasSavedDraft = false; let autosaveTimeout; let isSavingDraft = false; From f6bb7e1d787c29a5290cce966732de85ee468ecd Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:41:08 -0500 Subject: [PATCH 08/15] work in progress --- handlers/templates/pages/reviews-edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 862837cc..29888cb6 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -109,7 +109,7 @@ const url = draftId ? `/reviews/drafts/${draftId}` : "/reviews/drafts"; isSavingDraft = true; - if (!lastSavedAt && draftStatusEl) { + if (!hasSavedDraft && draftStatusEl) { draftStatusEl.textContent = "Saving draft..."; draftStatusEl.classList.remove("text-danger"); draftStatusEl.classList.add("text-muted"); From c4eae0885e64787896453ec36d0b25e99ef59caa Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Fri, 9 Jan 2026 09:44:24 -0500 Subject: [PATCH 09/15] Fade out the draft saved note --- handlers/templates/pages/reviews-edit.html | 60 +++++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 29888cb6..5d85aedc 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -65,8 +65,10 @@ const form = document.querySelector("form"); const draftIdInput = document.getElementById("draft-id"); const draftStatusEl = document.getElementById("draft-status"); + const draftSpinnerEl = document.getElementById("draft-spinner"); const autosaveEnabled = form?.dataset.autosave?.trim() === "true"; let hasSavedDraft = false; + let fadeTimeout; let autosaveTimeout; let isSavingDraft = false; let pendingSave = false; @@ -79,11 +81,13 @@ draftStatusEl.textContent = "Saved draft"; draftStatusEl.classList.remove("text-danger"); draftStatusEl.classList.add("text-muted"); + scheduleFade(); return; } draftStatusEl.textContent = "Draft will auto-save."; draftStatusEl.classList.remove("text-danger"); draftStatusEl.classList.add("text-muted"); + scheduleFade(); } function showDraftError(message) { @@ -93,6 +97,34 @@ draftStatusEl.textContent = message; draftStatusEl.classList.add("text-danger"); draftStatusEl.classList.remove("text-muted"); + if (fadeTimeout) { + window.clearTimeout(fadeTimeout); + } + draftStatusEl.classList.remove("opacity-0"); + } + + function scheduleFade() { + if (!draftStatusEl) { + return; + } + if (fadeTimeout) { + window.clearTimeout(fadeTimeout); + } + draftStatusEl.classList.remove("opacity-0"); + fadeTimeout = window.setTimeout(() => { + draftStatusEl.classList.add("opacity-0"); + }, 3000); + } + + function setSpinnerVisible(isVisible) { + if (!draftSpinnerEl) { + return; + } + if (isVisible) { + draftSpinnerEl.classList.remove("invisible"); + } else { + draftSpinnerEl.classList.add("invisible"); + } } async function saveDraft() { @@ -113,7 +145,9 @@ draftStatusEl.textContent = "Saving draft..."; draftStatusEl.classList.remove("text-danger"); draftStatusEl.classList.add("text-muted"); + scheduleFade(); } + setSpinnerVisible(true); try { const payload = new URLSearchParams(new FormData(form)); const response = await fetch(url, { @@ -143,6 +177,7 @@ } catch (err) { showDraftError("Failed to save draft."); } finally { + setSpinnerVisible(false); isSavingDraft = false; if (pendingSave) { pendingSave = false; @@ -164,6 +199,7 @@ } if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) { scheduleAutosave(); + scheduleFade(); } }; form.addEventListener("input", handleAutoSaveEvent); @@ -290,13 +326,23 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ if $enableAutosave }} -
    +
    + +
    +
    {{ end }}

    formatting options  Date: Fri, 9 Jan 2026 11:39:56 -0500 Subject: [PATCH 10/15] Fix e2e tests --- e2e/reviewDrafts.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts index c9a97d35..ebe08b50 100644 --- a/e2e/reviewDrafts.spec.ts +++ b/e2e/reviewDrafts.spec.ts @@ -38,7 +38,7 @@ test("auto-saves a draft and lets the user resume", async ({ page }) => { ); await page.getByRole("button", { name: "Publish" }).click(); - await expect(page).toHaveURL("/movies/3"); + await expect(page).toHaveURL(/\/movies\/3(#review\d+)?/); }); test("redirects to an existing draft when starting the same review", async ({ @@ -67,5 +67,5 @@ test("redirects to an existing draft when starting the same review", async ({ await page.getByText("Slow Learners (2015)").click(); await expect(page).toHaveURL(/\/reviews\/\d+\/edit/); - await expect(page.getByText("Draft")).toBeVisible(); + await expect(page.locator("h2 .badge", { hasText: "Draft" })).toBeVisible(); }); From 7bb64587461f92651878d743a7813c60eb52c297 Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Wed, 17 Jun 2026 00:17:38 +0000 Subject: [PATCH 11/15] Simplify review-drafts feature per code review Reduce duplication and coupling in the drafts feature: - Collapse the two draft-publish paths into one: drop the draft-id branch from POST /reviews and publish only via PUT /reviews/{id} ?publish=true. The editor's autosave already creates a draft, so the new-review form rewrites its publish request to that draft via an htmx:configRequest handler instead of POSTing a duplicate review. - Extract loadOwnedReview, reviewFromPostRequest, formBool, and publishedReviewRoute helpers; route both handlers through them. - Allow drafts to save with an empty watch date (parse functions take a requireWatchDate flag) so autosave no longer 400s on a cleared date. - Merge relativeDraftDate into relativeCommentDate (compute time.Since once) and drop the dead FuncMap registration. - Enforce one draft per (owner, media, season) with a partial unique index, and simplify findExistingDraft to return the single match. - De-duplicate the editing button block and fix the data-autosave attribute whitespace in reviews-edit.html. Co-Authored-By: Claude Opus 4.8 --- handlers/reviews.go | 217 +++++++++--------- handlers/reviews_drafts.go | 67 ++---- handlers/reviews_test.go | 7 +- handlers/templates/pages/reviews-drafts.html | 2 +- handlers/templates/pages/reviews-edit.html | 85 ++++--- handlers/views.go | 48 +--- store/sqlite/migrations/015-review-drafts.sql | 13 ++ 7 files changed, 184 insertions(+), 255 deletions(-) diff --git a/handlers/reviews.go b/handlers/reviews.go index b209bc77..98345113 100644 --- a/handlers/reviews.go +++ b/handlers/reviews.go @@ -18,7 +18,6 @@ type reviewPostRequest struct { Rating screenjournal.Rating WatchDate screenjournal.WatchDate Blurb screenjournal.Blurb - DraftID *screenjournal.ReviewID } type reviewPutRequest struct { @@ -30,87 +29,18 @@ type reviewPutRequest struct { func (s Server) reviewsPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - req, err := parseReviewPostRequest(r) + req, err := parseReviewPostRequest(r, true) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) log.Printf("couldn't parse review POST request: %v", err) return } - if req.DraftID != nil { - review, err := s.store.ReadReview(*req.DraftID) - if err == store.ErrReviewNotFound { - http.Error(w, "Draft not found", http.StatusNotFound) - return - } else if err != nil { - http.Error(w, fmt.Sprintf("Failed to read draft: %v", err), http.StatusInternalServerError) - return - } - - loggedInUsername := mustGetUsernameFromContext(r.Context()) - if !review.Owner.Equal(loggedInUsername) { - http.Error(w, "You can't edit another user's draft", http.StatusForbidden) - return - } - - if !review.IsDraft { - http.Error(w, "Draft already published", http.StatusBadRequest) - return - } - - review.Rating = req.Rating - review.Blurb = req.Blurb - review.Watched = req.WatchDate - review.IsDraft = false - - if err := s.store.UpdateReview(review); err != nil { - log.Printf("failed to publish draft: %v", err) - http.Error(w, fmt.Sprintf("Failed to save review: %v", err), http.StatusInternalServerError) - return - } - - s.announcer.AnnounceNewReview(review) - - if review.MediaType() == screenjournal.MediaTypeMovie { - http.Redirect(w, r, fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()), http.StatusSeeOther) - } else { - http.Redirect(w, r, fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()), http.StatusSeeOther) - } + review, ok := s.reviewFromPostRequest(w, req, mustGetUsernameFromContext(r.Context()), false) + if !ok { return } - review := screenjournal.Review{ - Owner: mustGetUsernameFromContext(r.Context()), - TvShowSeason: req.TvShowSeason, - Rating: req.Rating, - Watched: req.WatchDate, - Blurb: req.Blurb, - IsDraft: false, - Comments: []screenjournal.ReviewComment{}, - } - - if req.MediaType == screenjournal.MediaTypeMovie { - review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) - if err == store.ErrMovieNotFound { - http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } - } else if req.MediaType == screenjournal.MediaTypeTvShow { - review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) - if err == store.ErrTvShowNotFound { - http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } - } - review.ID, err = s.store.InsertReview(review) if err != nil { log.Printf("failed to save review: %v", err) @@ -120,12 +50,7 @@ func (s Server) reviewsPost() http.HandlerFunc { s.announcer.AnnounceNewReview(review) - if review.MediaType() == screenjournal.MediaTypeMovie { - http.Redirect(w, r, fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()), http.StatusSeeOther) - } else { - http.Redirect(w, r, fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()), http.StatusSeeOther) - } - + http.Redirect(w, r, publishedReviewRoute(review), http.StatusSeeOther) } } @@ -137,22 +62,12 @@ func (s Server) reviewsPut() http.HandlerFunc { return } - review, err := s.store.ReadReview(id) - if err == store.ErrReviewNotFound { - http.Error(w, "Review not found", http.StatusNotFound) - return - } else if err != nil { - http.Error(w, fmt.Sprintf("Failed to read review: %v", err), http.StatusInternalServerError) + review, ok := s.loadOwnedReview(w, r, id) + if !ok { return } - loggedInUsername := mustGetUsernameFromContext(r.Context()) - if !review.Owner.Equal(loggedInUsername) { - http.Error(w, "You can't edit another user's review", http.StatusForbidden) - return - } - - parsedRequest, err := parseReviewPutRequest(r) + parsedRequest, err := parseReviewPutRequest(r, true) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) return @@ -180,13 +95,7 @@ func (s Server) reviewsPut() http.HandlerFunc { s.announcer.AnnounceNewReview(review) } - var newRoute string - if review.MediaType() == screenjournal.MediaTypeMovie { - newRoute = fmt.Sprintf("/movies/%d", review.Movie.ID.Int64()) - } else { - newRoute = fmt.Sprintf("/tv-shows/%d?season=%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8()) - } - http.Redirect(w, r, newRoute, http.StatusSeeOther) + http.Redirect(w, r, publishedReviewRoute(review), http.StatusSeeOther) } } @@ -223,7 +132,10 @@ func (s Server) reviewsDelete() http.HandlerFunc { } } -func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { +// parseReviewPostRequest parses the fields shared by published reviews and +// drafts. When requireWatchDate is false (drafts), an empty watch date is +// allowed so that incomplete drafts can still be saved. +func parseReviewPostRequest(r *http.Request, requireWatchDate bool) (reviewPostRequest, error) { if err := r.ParseForm(); err != nil { log.Printf("failed to decode review POST request: %v", err) return reviewPostRequest{}, err @@ -232,14 +144,6 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { parsed := reviewPostRequest{} var err error - if draftRaw := r.PostFormValue("draft-id"); draftRaw != "" { - draftID, err := parse.ReviewIDFromString(draftRaw) - if err != nil { - return reviewPostRequest{}, err - } - parsed.DraftID = &draftID - } - if parsed.MediaType, err = parse.MediaType(r.PostFormValue("media-type")); err != nil { return reviewPostRequest{}, err } @@ -258,7 +162,7 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { return reviewPostRequest{}, err } - if parsed.WatchDate, err = parse.WatchDate(r.PostFormValue("watch-date")); err != nil { + if parsed.WatchDate, err = parseWatchDate(r, requireWatchDate); err != nil { return reviewPostRequest{}, err } @@ -269,7 +173,9 @@ func parseReviewPostRequest(r *http.Request) (reviewPostRequest, error) { return parsed, nil } -func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { +// parseReviewPutRequest parses an update to an existing review or draft. When +// requireWatchDate is false (drafts), an empty watch date is allowed. +func parseReviewPutRequest(r *http.Request, requireWatchDate bool) (reviewPutRequest, error) { if err := r.ParseForm(); err != nil { log.Printf("failed to decode review PUT request: %v", err) return reviewPutRequest{}, err @@ -281,7 +187,7 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return reviewPutRequest{}, err } - if parsed.Watched, err = parse.WatchDate(r.PostFormValue("watch-date")); err != nil { + if parsed.Watched, err = parseWatchDate(r, requireWatchDate); err != nil { return reviewPutRequest{}, err } @@ -289,12 +195,97 @@ func parseReviewPutRequest(r *http.Request) (reviewPutRequest, error) { return reviewPutRequest{}, err } - publishRaw := r.PostFormValue("publish") - parsed.Publish = publishRaw == "true" || publishRaw == "1" + parsed.Publish = formBool(r, "publish") return parsed, nil } +// parseWatchDate parses the watch-date form field. When the field is optional +// (drafts) and empty, it returns the zero WatchDate without error. +func parseWatchDate(r *http.Request, required bool) (screenjournal.WatchDate, error) { + raw := r.PostFormValue("watch-date") + if !required && raw == "" { + return screenjournal.WatchDate{}, nil + } + return parse.WatchDate(raw) +} + +// formBool reports whether the named form field holds a truthy value. +func formBool(r *http.Request, field string) bool { + v := r.PostFormValue(field) + return v == "true" || v == "1" +} + +// loadOwnedReview reads the review at id and verifies the logged-in user owns +// it. On any failure it writes the appropriate error response and returns +// ok=false. +func (s Server) loadOwnedReview(w http.ResponseWriter, r *http.Request, id screenjournal.ReviewID) (screenjournal.Review, bool) { + review, err := s.store.ReadReview(id) + if err == store.ErrReviewNotFound { + http.Error(w, "Review not found", http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + http.Error(w, fmt.Sprintf("Failed to read review: %v", err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + + if !review.Owner.Equal(mustGetUsernameFromContext(r.Context())) { + http.Error(w, "You can't edit another user's review", http.StatusForbidden) + return screenjournal.Review{}, false + } + + return review, true +} + +// reviewFromPostRequest builds a Review from a parsed POST request, resolving +// its movie or TV show from the TMDB ID. On any failure it writes the +// appropriate error response and returns ok=false. +func (s Server) reviewFromPostRequest(w http.ResponseWriter, req reviewPostRequest, owner screenjournal.Username, isDraft bool) (screenjournal.Review, bool) { + review := screenjournal.Review{ + Owner: owner, + TvShowSeason: req.TvShowSeason, + Rating: req.Rating, + Watched: req.WatchDate, + Blurb: req.Blurb, + IsDraft: isDraft, + Comments: []screenjournal.ReviewComment{}, + } + + var err error + if req.MediaType == screenjournal.MediaTypeMovie { + review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) + if err == store.ErrMovieNotFound { + http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + } else if req.MediaType == screenjournal.MediaTypeTvShow { + review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) + if err == store.ErrTvShowNotFound { + http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) + return screenjournal.Review{}, false + } else if err != nil { + log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) + http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) + return screenjournal.Review{}, false + } + } + + return review, true +} + +// publishedReviewRoute returns the URL of a published review, anchored to the +// review on its movie or TV show page. +func publishedReviewRoute(review screenjournal.Review) string { + if review.MediaType() == screenjournal.MediaTypeMovie { + return fmt.Sprintf("/movies/%d#review%d", review.Movie.ID.Int64(), review.ID.UInt64()) + } + return fmt.Sprintf("/tv-shows/%d?season=%d#review%d", review.TvShow.ID.Int64(), review.TvShowSeason.UInt8(), review.ID.UInt64()) +} + func (s Server) moviefromTmdbID(db sqlite.Store, tmdbID screenjournal.TmdbID) (screenjournal.Movie, error) { movie, err := db.ReadMovieByTmdbID(tmdbID) if err != nil && err != store.ErrMovieNotFound { diff --git a/handlers/reviews_drafts.go b/handlers/reviews_drafts.go index f891386e..6729c207 100644 --- a/handlers/reviews_drafts.go +++ b/handlers/reviews_drafts.go @@ -16,45 +16,17 @@ type reviewDraftResponse struct { func (s Server) reviewsDraftsPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - req, err := parseReviewPostRequest(r) + req, err := parseReviewPostRequest(r, false) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) return } - saveDraftIntent := r.PostFormValue("save-draft") == "true" || - r.PostFormValue("save-draft") == "1" + saveDraftIntent := formBool(r, "save-draft") loggedInUsername := mustGetUsernameFromContext(r.Context()) - review := screenjournal.Review{ - Owner: loggedInUsername, - TvShowSeason: req.TvShowSeason, - Rating: req.Rating, - Watched: req.WatchDate, - Blurb: req.Blurb, - IsDraft: true, - Comments: []screenjournal.ReviewComment{}, - } - - if req.MediaType == screenjournal.MediaTypeMovie { - review.Movie, err = s.moviefromTmdbID(s.store, req.TmdbID) - if err == store.ErrMovieNotFound { - http.Error(w, fmt.Sprintf("Could not find movie with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for movie with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up movie with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } - } else if req.MediaType == screenjournal.MediaTypeTvShow { - review.TvShow, err = s.tvShowfromTmdbID(s.store, req.TmdbID) - if err == store.ErrTvShowNotFound { - http.Error(w, fmt.Sprintf("Could not find tv show with TMDB ID: %v", req.TmdbID), http.StatusNotFound) - return - } else if err != nil { - log.Printf("failed to get local media ID for TV show with TMDB ID %v: %v", req.TmdbID, err) - http.Error(w, fmt.Sprintf("Failed to look up TV show with TMDB ID: %v: %v", req.TmdbID, err), http.StatusInternalServerError) - return - } + review, ok := s.reviewFromPostRequest(w, req, loggedInUsername, true) + if !ok { + return } existingDraft, err := s.findExistingDraft(r, loggedInUsername, review) @@ -113,14 +85,11 @@ func (s Server) findExistingDraft(r *http.Request, owner screenjournal.Username, if len(drafts) == 0 { return nil, nil } - - latest := drafts[0] - for _, candidate := range drafts[1:] { - if candidate.Modified.After(latest.Modified) { - latest = candidate - } - } - return &latest, nil + // A partial unique index (see migration 015) guarantees at most one draft + // per (owner, media, season), so the first match is the only match. This + // dedup is load-bearing: it's what keeps the editor's autosave from creating + // duplicate drafts when its initial "create" requests race. + return &drafts[0], nil } func (s Server) reviewsDraftsPut() http.HandlerFunc { @@ -131,18 +100,8 @@ func (s Server) reviewsDraftsPut() http.HandlerFunc { return } - review, err := s.store.ReadReview(id) - if err == store.ErrReviewNotFound { - http.Error(w, "Draft not found", http.StatusNotFound) - return - } else if err != nil { - http.Error(w, fmt.Sprintf("Failed to read draft: %v", err), http.StatusInternalServerError) - return - } - - loggedInUsername := mustGetUsernameFromContext(r.Context()) - if !review.Owner.Equal(loggedInUsername) { - http.Error(w, "You can't edit another user's draft", http.StatusForbidden) + review, ok := s.loadOwnedReview(w, r, id) + if !ok { return } @@ -151,7 +110,7 @@ func (s Server) reviewsDraftsPut() http.HandlerFunc { return } - parsedRequest, err := parseReviewPutRequest(r) + parsedRequest, err := parseReviewPutRequest(r, false) if err != nil { http.Error(w, fmt.Sprintf("Invalid request: %v", err), http.StatusBadRequest) return diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index 08ee89d2..c778e9dd 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -1176,7 +1176,7 @@ func TestReviewsDraftsPut(t *testing.T) { } } -func TestReviewsPostPublishesDraft(t *testing.T) { +func TestReviewsPutPublishesDraft(t *testing.T) { dataStore := test_sqlite.New() mockUser := screenjournal.User{ @@ -1228,11 +1228,10 @@ func TestReviewsPostPublishesDraft(t *testing.T) { }) payload := fmt.Sprintf( - "draft-id=%d&media-type=movie&tmdb-id=38&rating=5&watch-date=2022-10-30&blurb=%s", - draftID, + "publish=true&rating=5&watch-date=2022-10-30&blurb=%s", url.QueryEscape("Final thoughts"), ) - req, err := http.NewRequest("POST", "/reviews", strings.NewReader(payload)) + req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/%d", draftID), strings.NewReader(payload)) if err != nil { t.Fatal(err) } diff --git a/handlers/templates/pages/reviews-drafts.html b/handlers/templates/pages/reviews-drafts.html index b8273b22..867d17b6 100644 --- a/handlers/templates/pages/reviews-drafts.html +++ b/handlers/templates/pages/reviews-drafts.html @@ -35,7 +35,7 @@

    Last saved - {{ relativeDraftDate .Modified }} + {{ relativeCommentDate .Modified }}

    {{ with .Blurb | elideBlurb }}

    diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 30746e59..c05c1c83 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -81,7 +81,7 @@ const draftIdInput = document.getElementById("draft-id"); const draftStatusEl = document.getElementById("draft-status"); const draftSpinnerEl = document.getElementById("draft-spinner"); - const autosaveEnabled = form?.dataset.autosave?.trim() === "true"; + const autosaveEnabled = form?.dataset.autosave === "true"; let hasSavedDraft = false; let fadeTimeout; let autosaveTimeout; @@ -219,6 +219,22 @@ }; form.addEventListener("input", handleAutoSaveEvent); form.addEventListener("change", handleAutoSaveEvent); + + // When autosave has already created a draft, publish by updating that + // draft (PUT /reviews/{id}?publish=true) instead of POSTing a brand-new + // review, which would orphan the draft. + form.addEventListener("htmx:configRequest", (evt) => { + const draftId = draftIdInput?.value?.trim(); + if ( + draftId && + evt.detail.verb.toLowerCase() === "post" && + evt.detail.path === "/reviews" + ) { + evt.detail.verb = "put"; + evt.detail.path = `/reviews/${draftId}`; + evt.detail.parameters["publish"] = "true"; + } + }); } if (autosaveEnabled) { if (draftStatusEl?.dataset.hadSavedDraft === "true") { @@ -275,11 +291,7 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    hx-target="body" hx-push-url="true" hx-disabled-elt="input, select, textarea, .btn" - data-autosave="{{ if $enableAutosave }} - true - {{ else }} - false - {{ end }}" + data-autosave="{{ if $enableAutosave }}true{{ else }}false{{ end }}" > Season {{ .Review.TvShowSeason.UInt8 }}
    {{ if $isEditing }} - {{ if $isDraft }} -
    - {{ else }}
    diff --git a/handlers/views.go b/handlers/views.go index 31812da2..b1004dc0 100644 --- a/handlers/views.go +++ b/handlers/views.go @@ -164,7 +164,6 @@ var reviewPageFns = template.FuncMap{ "formatDate": func(t time.Time) string { return t.Format(time.DateOnly) }, - "relativeDraftDate": relativeDraftDate, } func (s Server) indexGet() http.HandlerFunc { @@ -374,8 +373,8 @@ func (s Server) reviewsDraftsGet() http.HandlerFunc { } return plaintext }, - "posterPathToURL": posterPathToURL, - "relativeDraftDate": relativeDraftDate, + "posterPathToURL": posterPathToURL, + "relativeCommentDate": relativeCommentDate, } t := template.Must( @@ -1127,15 +1126,20 @@ func formatWatchDate(t screenjournal.WatchDate) string { return t.Time().Format(time.DateOnly) } +// relativeCommentDate renders a coarse, human-friendly age for a timestamp +// (e.g. "just now", "an hour ago", "3 weeks ago"). It's used wherever we show +// when something was last touched, such as comments and draft reviews. func relativeCommentDate(t time.Time) string { - minutesAgo := int(time.Since(t).Minutes()) + elapsed := time.Since(t) + + minutesAgo := int(elapsed.Minutes()) if minutesAgo < 1 { return "just now" } if minutesAgo == 1 { return "a minute ago" } - hoursAgo := int(time.Since(t).Hours()) + hoursAgo := int(elapsed.Hours()) if hoursAgo < 1 { return fmt.Sprintf("%d minutes ago", minutesAgo) } @@ -1146,7 +1150,7 @@ func relativeCommentDate(t time.Time) string { return fmt.Sprintf("%d hours ago", hoursAgo) } - daysAgo := int(time.Since(t).Hours() / 24) + daysAgo := int(elapsed.Hours() / 24) weeksAgo := int(daysAgo / 7) switch { case daysAgo == 1: @@ -1169,38 +1173,6 @@ func relativeCommentDate(t time.Time) string { return fmt.Sprintf("%d years ago", yearsAgo) } -func relativeDraftDate(t time.Time) string { - secondsAgo := int(time.Since(t).Seconds()) - if secondsAgo < 60 { - if secondsAgo <= 1 { - return "1 second ago" - } - return fmt.Sprintf("%d seconds ago", secondsAgo) - } - - minutesAgo := int(time.Since(t).Minutes()) - if minutesAgo == 1 { - return "1 minute ago" - } - if minutesAgo < 60 { - return fmt.Sprintf("%d minutes ago", minutesAgo) - } - - hoursAgo := int(time.Since(t).Hours()) - if hoursAgo == 1 { - return "1 hour ago" - } - if hoursAgo < 24 { - return fmt.Sprintf("%d hours ago", hoursAgo) - } - - daysAgo := int(time.Since(t).Hours() / 24) - if daysAgo == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", daysAgo) -} - func formatIso8601Datetime(t time.Time) string { return t.Format("2006-01-02 3:04 pm") } diff --git a/store/sqlite/migrations/015-review-drafts.sql b/store/sqlite/migrations/015-review-drafts.sql index c5d5e2e6..b76558f2 100644 --- a/store/sqlite/migrations/015-review-drafts.sql +++ b/store/sqlite/migrations/015-review-drafts.sql @@ -1,2 +1,15 @@ ALTER TABLE reviews ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0; + +-- A user can have at most one draft per movie or per (TV show, season). We +-- COALESCE the nullable media columns because SQLite treats each NULL as +-- distinct in a UNIQUE index, which would otherwise let duplicate movie drafts +-- (tv_show_id/tv_show_season NULL) slip through. +CREATE UNIQUE INDEX reviews_unique_draft_per_media +ON reviews ( + review_owner, + COALESCE(movie_id, -1), + COALESCE(tv_show_id, -1), + COALESCE(tv_show_season, -1) +) +WHERE is_draft = 1; From 6ebecce4970e6910fb651a6d88f38bb063a0873a Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Wed, 17 Jun 2026 06:43:46 -0400 Subject: [PATCH 12/15] Prettier --- handlers/templates/pages/reviews-edit.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index c05c1c83..518f97d4 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -291,7 +291,11 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    hx-target="body" hx-push-url="true" hx-disabled-elt="input, select, textarea, .btn" - data-autosave="{{ if $enableAutosave }}true{{ else }}false{{ end }}" + data-autosave="{{ if $enableAutosave }} + true + {{ else }} + false + {{ end }}" > Season {{ .Review.TvShowSeason.UInt8 }} Cancel
    From 2c605f8013cf8bd35aec7b9bf45ddcb55d798004 Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Wed, 17 Jun 2026 10:46:10 +0000 Subject: [PATCH 13/15] Fix SQL migration indentation --- store/sqlite/migrations/015-review-drafts.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/store/sqlite/migrations/015-review-drafts.sql b/store/sqlite/migrations/015-review-drafts.sql index b76558f2..df626535 100644 --- a/store/sqlite/migrations/015-review-drafts.sql +++ b/store/sqlite/migrations/015-review-drafts.sql @@ -7,9 +7,9 @@ ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0; -- (tv_show_id/tv_show_season NULL) slip through. CREATE UNIQUE INDEX reviews_unique_draft_per_media ON reviews ( - review_owner, - COALESCE(movie_id, -1), - COALESCE(tv_show_id, -1), - COALESCE(tv_show_season, -1) + review_owner, + COALESCE(movie_id, -1), + COALESCE(tv_show_id, -1), + COALESCE(tv_show_season, -1) ) WHERE is_draft = 1; From 9eccab340530f53ca114fb09340001be1bb2f78f Mon Sep 17 00:00:00 2001 From: Michael Lynch Date: Sat, 20 Jun 2026 00:54:49 +0000 Subject: [PATCH 14/15] Fix e2e tests after master merge --- e2e/reviewDrafts.spec.ts | 16 +++++++-------- e2e/reviews.spec.ts | 24 +++++++++++----------- handlers/templates/pages/reviews-edit.html | 6 +++--- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/e2e/reviewDrafts.spec.ts b/e2e/reviewDrafts.spec.ts index ebe08b50..b765ad9c 100644 --- a/e2e/reviewDrafts.spec.ts +++ b/e2e/reviewDrafts.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures"; import { populateDummyData } from "./helpers/db"; import { loginAsUserA } from "./helpers/login"; @@ -13,14 +13,13 @@ test("auto-saves a draft and lets the user resume", async ({ page }) => { await page.getByPlaceholder("Search").pressSequentially("slow lear"); await page.getByText("Slow Learners (2015)").click(); - await page.getByLabel("When did you watch?").fill("2024-10-10"); - const draftResponse = page.waitForResponse( (response) => response.url().includes("/reviews/drafts") && - response.request().method() === "POST" && - response.status() === 201 + ["POST", "PUT"].includes(response.request().method()) && + response.ok() ); + await page.getByLabel("When did you watch?").fill("2024-10-10"); await page.getByLabel("Other thoughts?").fill("Draft thoughts"); await draftResponse; @@ -49,14 +48,13 @@ test("redirects to an existing draft when starting the same review", async ({ await page.getByPlaceholder("Search").pressSequentially("slow lear"); await page.getByText("Slow Learners (2015)").click(); - await page.getByLabel("When did you watch?").fill("2024-10-10"); - const draftResponse = page.waitForResponse( (response) => response.url().includes("/reviews/drafts") && - response.request().method() === "POST" && - response.status() === 201 + ["POST", "PUT"].includes(response.request().method()) && + response.ok() ); + await page.getByLabel("When did you watch?").fill("2024-10-10"); await page.getByLabel("Other thoughts?").fill("Second draft"); await draftResponse; diff --git a/e2e/reviews.spec.ts b/e2e/reviews.spec.ts index 7248f384..c66e5a3a 100644 --- a/e2e/reviews.spec.ts +++ b/e2e/reviews.spec.ts @@ -153,7 +153,7 @@ test("adds a new movie rating and fills in only required fields", async ({ await page.getByLabel("Rating").selectOption({ label: "1.0" }); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -204,7 +204,7 @@ Haley Joel Osment is going places! I can't believe Bruce Willis was a ghost the whole time!`); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -248,7 +248,7 @@ test("adds a new TV show rating and fills in only required fields", async ({ await page.getByLabel("Rating").selectOption({ label: "4.5" }); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/tv-shows/2?season=5"); @@ -308,7 +308,7 @@ test("views a TV show with an existing review and adds a new review", async ({ await page.getByLabel("Rating").selectOption({ label: "5.0" }); await page.getByLabel("Other thoughts?").fill("I liked it, too!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/tv-shows/1?season=2"); }); @@ -345,7 +345,7 @@ Daniel Radcliffe is **fantastic**, and it's a great film role for Rainn Wilson. You'll like it if you enjoy things like _Children's Hospital_, _Comedy Bang Bang_, or _Popstar_.`); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -430,7 +430,7 @@ test("adds a new rating and fills all fields", async ({ page }) => { await page.getByLabel("Other thoughts?").fill("My favorite movie!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -486,7 +486,7 @@ test("HTML tags in reviews are stripped from review excerpt", async ({ .getByLabel("Other thoughts?") .fill("This is the best movie ever!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -523,7 +523,7 @@ test("adds a new movie rating and edits the details", async ({ page }) => { await page.getByLabel("Other thoughts?").fill("My favorite movie!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -601,7 +601,7 @@ test("adds a new rating and cancels the edit", async ({ page }) => { .getByLabel("Other thoughts?") .fill("What an English patient he was!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -663,7 +663,7 @@ test("editing another user's review fails", async ({ .getByLabel("Other thoughts?") .fill("What an English patient he was!"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); @@ -706,7 +706,7 @@ test("views a movie with an existing review and adds a new review", async ({ await page.getByLabel("Rating").selectOption({ label: "5.0" }); await page.getByLabel("Other thoughts?").fill("Relevant as ever"); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/1"); }); @@ -929,7 +929,7 @@ test("adds a new movie rating without a numeric rating", async ({ page }) => { .getByLabel("Other thoughts?") .fill("A classic, but I don't want to give it a numeric rating."); - await page.locator("form input[type='submit']").click(); + await page.getByRole("button", { name: "Publish" }).click(); await expect(page).toHaveURL("/movies/3"); diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index 518f97d4..a049b800 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -291,11 +291,11 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    hx-target="body" hx-push-url="true" hx-disabled-elt="input, select, textarea, .btn" - data-autosave="{{ if $enableAutosave }} + data-autosave="{{ if $enableAutosave -}} true - {{ else }} + {{- else -}} false - {{ end }}" + {{- end }}" > Date: Mon, 22 Jun 2026 20:17:30 +0000 Subject: [PATCH 15/15] Infer draft publication from review update --- handlers/reviews.go | 11 +---------- handlers/reviews_test.go | 2 +- handlers/templates/pages/reviews-edit.html | 7 +++---- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/handlers/reviews.go b/handlers/reviews.go index 98345113..2e262286 100644 --- a/handlers/reviews.go +++ b/handlers/reviews.go @@ -24,7 +24,6 @@ type reviewPutRequest struct { Rating screenjournal.Rating Blurb screenjournal.Blurb Watched screenjournal.WatchDate - Publish bool } func (s Server) reviewsPost() http.HandlerFunc { @@ -77,13 +76,7 @@ func (s Server) reviewsPut() http.HandlerFunc { review.Blurb = parsedRequest.Blurb review.Watched = parsedRequest.Watched wasDraft := review.IsDraft - if parsedRequest.Publish { - if !review.IsDraft { - http.Error(w, "Review already published", http.StatusBadRequest) - return - } - review.IsDraft = false - } + review.IsDraft = false if err := s.store.UpdateReview(review); err != nil { log.Printf("failed to update review: %v", err) @@ -195,8 +188,6 @@ func parseReviewPutRequest(r *http.Request, requireWatchDate bool) (reviewPutReq return reviewPutRequest{}, err } - parsed.Publish = formBool(r, "publish") - return parsed, nil } diff --git a/handlers/reviews_test.go b/handlers/reviews_test.go index c778e9dd..3da914fa 100644 --- a/handlers/reviews_test.go +++ b/handlers/reviews_test.go @@ -1228,7 +1228,7 @@ func TestReviewsPutPublishesDraft(t *testing.T) { }) payload := fmt.Sprintf( - "publish=true&rating=5&watch-date=2022-10-30&blurb=%s", + "rating=5&watch-date=2022-10-30&blurb=%s", url.QueryEscape("Final thoughts"), ) req, err := http.NewRequest("PUT", fmt.Sprintf("/reviews/%d", draftID), strings.NewReader(payload)) diff --git a/handlers/templates/pages/reviews-edit.html b/handlers/templates/pages/reviews-edit.html index a049b800..c35bbee4 100644 --- a/handlers/templates/pages/reviews-edit.html +++ b/handlers/templates/pages/reviews-edit.html @@ -221,8 +221,8 @@ form.addEventListener("change", handleAutoSaveEvent); // When autosave has already created a draft, publish by updating that - // draft (PUT /reviews/{id}?publish=true) instead of POSTing a brand-new - // review, which would orphan the draft. + // draft instead of POSTing a brand-new review, which would orphan the + // draft. form.addEventListener("htmx:configRequest", (evt) => { const draftId = draftIdInput?.value?.trim(); if ( @@ -232,7 +232,6 @@ ) { evt.detail.verb = "put"; evt.detail.path = `/reviews/${draftId}`; - evt.detail.parameters["publish"] = "true"; } }); } @@ -409,7 +408,7 @@

    Season {{ .Review.TvShowSeason.UInt8 }}

    {{ if $isDraft }}
    -