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..0653d4d7 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; // 7 days — matches DEADLINE_HOURS in DeadlineCountdown + 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..b5450d28 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"; @@ -56,13 +57,18 @@ 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 ?? []; } +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 +533,14 @@ 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.map((s) => { + const expired = isStorylineExpired(s); + return { + value: String(s.storyline_id), + 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 ed6907c9..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 91acdf7f..fb2c6fa8 100644 --- a/src/components/AddPlotButton.tsx +++ b/src/components/AddPlotButton.tsx @@ -2,17 +2,42 @@ 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, + sunset, }: { storylineId: number; writerAddress: string; + lastPlotTime?: string | null; + sunset?: boolean; }) { const { address } = useAccount(); if (!address || address.toLowerCase() !== writerAddress.toLowerCase()) return null; + + const expired = sunset || (lastPlotTime ? isDeadlineExpired(lastPlotTime) : false); + + if (expired) { + return ( +
+ Deadline expired +
+ ); + } + return ( (null); 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}