From 545456228410b80f2a3a177a9625c1815567d008 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 12:10:17 +0100 Subject: [PATCH 1/3] =?UTF-8?q?[#804]=20Block=20plot=20creation=20when=20d?= =?UTF-8?q?eadline=20expired=20=E2=80=94=20frontend=20+=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. AddPlotButton: check last_plot_time + 168h, show disabled "Deadline expired" state instead of clickable link 2. Create page: filter expired storylines from dropdown 3. API index/plot: reject with 400 when deadline expired or sunset 4. Export DEADLINE_HOURS from DeadlineCountdown for reuse Fixes #804 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/OVERNIGHT-QUEUE.md | 44 ++++++++++++++++++---------- src/app/api/index/plot/route.ts | 20 +++++++++++++ src/app/create/page.tsx | 19 ++++++++---- src/app/story/[storylineId]/page.tsx | 2 +- src/components/AddPlotButton.tsx | 23 +++++++++++++++ src/components/DeadlineCountdown.tsx | 2 +- 6 files changed, 87 insertions(+), 23 deletions(-) diff --git a/docs/OVERNIGHT-QUEUE.md b/docs/OVERNIGHT-QUEUE.md index 87f6bd48..5d8eac5c 100644 --- a/docs/OVERNIGHT-QUEUE.md +++ b/docs/OVERNIGHT-QUEUE.md @@ -146,22 +146,34 @@ --- -## Tonight's Queue — Batch 61: Post-Review Fixes - -### 1. plotlink#796 — DonateWidget: unformatted balance shows raw 18-decimal values -- Same bug as #789 but in DonateWidget.tsx (lines ~152, 164) -- Apply same `formatTokenAmount` or extract to shared `src/lib/format.ts` -- Branch: `task/796-donate-format` - -### 2. plotlink#797 — Storyline header: RatingSummaryWithSeparator duplicates RatingSummary -- Refactor to compose existing `RatingSummary` instead of duplicating query+render -- Add `aria-hidden="true"` to decorative separator dot -- Branch: `task/797-rating-summary-dedup` - -### 3. plotlink#798 — Storyline stats: add min-w-0 overflow guard on mobile grid -- MCap + Supply grid children need `min-w-0` for 320px screens -- Prevents text overflow with large USD values + 24h% badge -- Branch: `task/798-stats-overflow-guard` +## Completed — Batch 61 + +- Batch 61: DonateWidget format #799, RatingSummary dedup #800, Stats overflow guard #801 + +--- + +## Tonight's Queue — Batch 62: Storyline Page Polish + Deadline Enforcement + +### 1. plotlink#802 — Storyline page: 3-col stats boxes like profile page, beside Moleskine on desktop +- Redesign Market Cap, Supply Minted, Deadline as bordered stat boxes matching profile page style +- Desktop: place in the header area next to the Moleskine cover +- Mobile: full-width row below header +- Branch: `task/802-storyline-stats-boxes` + +### 2. plotlink#803 — Storyline page: left-align title and info on mobile +- Mobile: title, rating, Writer/Plots/Genre rows should be left-aligned, not centered +- Moleskine cover can stay centered +- Desktop: no changes (already left-aligned) +- Branch: `task/803-storyline-mobile-left-align` + +### 3. plotlink#804 — Block new plot creation when deadline is expired +- `sunset` flag is never set to `true` by app code — button stays clickable after countdown expires +- Front-end: disable "+ Add a new Plot" button (visible but `opacity-50 pointer-events-none`) when `last_plot_time + 168h < now` +- Create page: show expired storylines in dropdown but disabled with "(expired)" label +- API: add deadline validation in `src/app/api/index/plot/route.ts` +- Optional: cron/trigger to set `sunset=true` for expired storylines +- Contract already enforces (`chainPlot()` reverts), this is UX + defense-in-depth +- Branch: `task/804-deadline-enforcement` --- diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 3790dedb..f578111c 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -113,6 +113,26 @@ export async function POST(req: Request) { return error("Supabase not configured", 500); } + // 7a. Check deadline — reject if storyline's 7-day deadline has expired + const DEADLINE_MS = 168 * 60 * 60 * 1000; + const { data: storylineRow } = await supabase + .from("storylines") + .select("last_plot_time, sunset") + .eq("storyline_id", Number(storylineId)) + .single(); + + if (storylineRow) { + if (storylineRow.sunset) { + return error("Storyline has sunset — no new plots allowed", 400); + } + if (storylineRow.last_plot_time) { + const deadline = new Date(storylineRow.last_plot_time).getTime() + DEADLINE_MS; + if (Date.now() > deadline) { + return error("Storyline deadline expired — no new plots allowed", 400); + } + } + } + const row: Database["public"]["Tables"]["plots"]["Insert"] = { storyline_id: Number(storylineId), plot_index: Number(plotIndex), diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index b338d80a..5c17bf7d 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -56,13 +56,20 @@ async function fetchWriterStorylines(address: string): Promise { .select("*") .eq("writer_address", address.toLowerCase()) .eq("hidden", false) - .eq("sunset", false) .eq("contract_address", STORY_FACTORY.toLowerCase()) .order("block_timestamp", { ascending: false }) .returns(); return data ?? []; } +const DEADLINE_MS = 168 * 60 * 60 * 1000; + +function isStorylineExpired(s: Storyline): boolean { + if (s.sunset) return true; + if (!s.last_plot_time) return false; + return Date.now() > new Date(s.last_plot_time).getTime() + DEADLINE_MS; +} + export default function CreatePageWrapper() { return ( @@ -527,10 +534,12 @@ function CreatePage() { onChange={(v) => setChainStorylineId(v ? Number(v) : null)} disabled={chainBusy} placeholder="Select a storyline" - options={storylines.map((s) => ({ - value: String(s.storyline_id), - label: `${s.title} (${s.plot_count} ${s.plot_count === 1 ? "plot" : "plots"})`, - }))} + options={storylines + .filter((s) => !isStorylineExpired(s)) + .map((s) => ({ + value: String(s.storyline_id), + label: `${s.title} (${s.plot_count} ${s.plot_count === 1 ? "plot" : "plots"})`, + }))} /> )} diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index ed6907c9..09db7b14 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -373,7 +373,7 @@ function StoryHeader({ {!storyline.sunset && ( - + )} ); diff --git a/src/components/AddPlotButton.tsx b/src/components/AddPlotButton.tsx index 91acdf7f..6cf5dffe 100644 --- a/src/components/AddPlotButton.tsx +++ b/src/components/AddPlotButton.tsx @@ -2,17 +2,40 @@ import { useAccount } from "wagmi"; import Link from "next/link"; +import { DEADLINE_HOURS } from "./DeadlineCountdown"; + +function isDeadlineExpired(lastPlotTime: string | null): boolean { + if (!lastPlotTime) return false; + const deadline = new Date(lastPlotTime).getTime() + DEADLINE_HOURS * 60 * 60 * 1000; + return Date.now() > deadline; +} export function AddPlotButton({ storylineId, writerAddress, + lastPlotTime, }: { storylineId: number; writerAddress: string; + lastPlotTime?: string | null; }) { const { address } = useAccount(); if (!address || address.toLowerCase() !== writerAddress.toLowerCase()) return null; + + const expired = lastPlotTime ? isDeadlineExpired(lastPlotTime) : false; + + if (expired) { + return ( +
+ Deadline expired +
+ ); + } + return ( (null); From 62c95dc83e22ebb2a38425d7eede50b8a230ddad Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 12:11:52 +0100 Subject: [PATCH 2/3] [#804] Consolidate DEADLINE_MS to single source Export DEADLINE_MS from DeadlineCountdown.tsx alongside DEADLINE_HOURS. Create page now imports DEADLINE_MS instead of hardcoding. API route keeps local constant (server-side can't import client component) but documents the source. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/index/plot/route.ts | 2 +- src/app/create/page.tsx | 3 +-- src/components/DeadlineCountdown.tsx | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index f578111c..0653d4d7 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -114,7 +114,7 @@ export async function POST(req: Request) { } // 7a. Check deadline — reject if storyline's 7-day deadline has expired - const DEADLINE_MS = 168 * 60 * 60 * 1000; + const DEADLINE_MS = 168 * 60 * 60 * 1000; // 7 days — matches DEADLINE_HOURS in DeadlineCountdown const { data: storylineRow } = await supabase .from("storylines") .select("last_plot_time, sunset") diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 5c17bf7d..d86888c8 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -14,6 +14,7 @@ import { usePublish, type PublishState } from "../../hooks/usePublish"; import { useChainPlot } from "../../hooks/useChainPlot"; import { usePublishIntent } from "../../hooks/usePublishIntent"; import { RecoveryBanner } from "../../components/RecoveryBanner"; +import { DEADLINE_MS } from "../../components/DeadlineCountdown"; import { storyFactoryAbi, storylineCreatedEvent } from "../../../lib/contracts/abi"; import { STORY_FACTORY, MCV2_BOND } from "../../../lib/contracts/constants"; import { supabase, type Storyline } from "../../../lib/supabase"; @@ -62,8 +63,6 @@ async function fetchWriterStorylines(address: string): Promise { return data ?? []; } -const DEADLINE_MS = 168 * 60 * 60 * 1000; - function isStorylineExpired(s: Storyline): boolean { if (s.sunset) return true; if (!s.last_plot_time) return false; diff --git a/src/components/DeadlineCountdown.tsx b/src/components/DeadlineCountdown.tsx index 783d42c1..1cf0ee92 100644 --- a/src/components/DeadlineCountdown.tsx +++ b/src/components/DeadlineCountdown.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; export const DEADLINE_HOURS = 168; +export const DEADLINE_MS = DEADLINE_HOURS * 60 * 60 * 1000; export function DeadlineCountdown({ lastPlotTime, hideLabel }: { lastPlotTime: string; hideLabel?: boolean }) { const [remaining, setRemaining] = useState(null); From 5f919ce4c76aee4531abadc03f61510d8afb0c5f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 12:13:19 +0100 Subject: [PATCH 3/3] [#804] Show expired storylines disabled instead of hidden 1. Create page: expired storylines shown in dropdown with "(expired)" label and disabled (not selectable). Added disabled option support to Select component. 2. Storyline page: AddPlotButton now shown for sunset storylines too, renders disabled "Deadline expired" state. Removed !sunset guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/create/page.tsx | 12 +++++++----- src/app/story/[storylineId]/page.tsx | 4 +--- src/components/AddPlotButton.tsx | 4 +++- src/components/Select.tsx | 22 +++++++++++++--------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index d86888c8..b5450d28 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -533,12 +533,14 @@ function CreatePage() { onChange={(v) => setChainStorylineId(v ? Number(v) : null)} disabled={chainBusy} placeholder="Select a storyline" - options={storylines - .filter((s) => !isStorylineExpired(s)) - .map((s) => ({ + options={storylines.map((s) => { + const expired = isStorylineExpired(s); + return { value: String(s.storyline_id), - label: `${s.title} (${s.plot_count} ${s.plot_count === 1 ? "plot" : "plots"})`, - }))} + label: `${s.title} (${s.plot_count} ${s.plot_count === 1 ? "plot" : "plots"})${expired ? " (expired)" : ""}`, + disabled: expired, + }; + })} /> )} diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 09db7b14..6d808923 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -372,9 +372,7 @@ function StoryHeader({ )} - {!storyline.sunset && ( - - )} + ); } diff --git a/src/components/AddPlotButton.tsx b/src/components/AddPlotButton.tsx index 6cf5dffe..fb2c6fa8 100644 --- a/src/components/AddPlotButton.tsx +++ b/src/components/AddPlotButton.tsx @@ -14,16 +14,18 @@ export function AddPlotButton({ storylineId, writerAddress, lastPlotTime, + sunset, }: { storylineId: number; writerAddress: string; lastPlotTime?: string | null; + sunset?: boolean; }) { const { address } = useAccount(); if (!address || address.toLowerCase() !== writerAddress.toLowerCase()) return null; - const expired = lastPlotTime ? isDeadlineExpired(lastPlotTime) : false; + const expired = sunset || (lastPlotTime ? isDeadlineExpired(lastPlotTime) : false); if (expired) { return ( diff --git a/src/components/Select.tsx b/src/components/Select.tsx index dbf513d8..0a4b162d 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -5,6 +5,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; export interface SelectOption { value: string; label: string; + disabled?: boolean; } interface SelectProps { @@ -80,7 +81,7 @@ export function Select({ break; case "Enter": e.preventDefault(); - if (focusIndex >= 0 && focusIndex < allOptions.length) { + if (focusIndex >= 0 && focusIndex < allOptions.length && !allOptions[focusIndex].disabled) { onChange(allOptions[focusIndex].value); setOpen(false); } @@ -135,17 +136,20 @@ export function Select({ aria-selected={opt.value === value} onMouseEnter={() => setFocusIndex(i)} onClick={() => { + if (opt.disabled) return; onChange(opt.value); setOpen(false); }} - className={`cursor-pointer px-3 py-2 text-sm ${ - opt.value === value - ? "bg-accent text-background" - : i === focusIndex - ? "bg-border/50 text-foreground" - : opt.value === "" - ? "text-muted hover:bg-border/30" - : "text-foreground hover:bg-border/30" + className={`px-3 py-2 text-sm ${ + opt.disabled + ? "text-muted opacity-50 cursor-default" + : opt.value === value + ? "bg-accent text-background cursor-pointer" + : i === focusIndex + ? "bg-border/50 text-foreground cursor-pointer" + : opt.value === "" + ? "text-muted hover:bg-border/30 cursor-pointer" + : "text-foreground hover:bg-border/30 cursor-pointer" }`} > {opt.label}