Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions docs/OVERNIGHT-QUEUE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

---

Expand Down
20 changes: 20 additions & 0 deletions src/app/api/index/plot/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { type Hex, decodeEventLog, encodeEventTopics } from "viem";
import { publicClient, getReceiptWithRetry } from "../../../../../lib/rpc";

Check warning on line 3 in src/app/api/index/plot/route.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'getReceiptWithRetry' is defined but never used
import { createServerClient } from "../../../../../lib/supabase";
import { validateRecentTx } from "../../../../../lib/index-auth";
import {
Expand Down Expand Up @@ -113,6 +113,26 @@
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),
Expand Down
20 changes: 15 additions & 5 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -56,13 +57,18 @@ async function fetchWriterStorylines(address: string): Promise<Storyline[]> {
.select("*")
.eq("writer_address", address.toLowerCase())
.eq("hidden", false)
.eq("sunset", false)
.eq("contract_address", STORY_FACTORY.toLowerCase())
.order("block_timestamp", { ascending: false })
.returns<Storyline[]>();
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 (
<Suspense>
Expand Down Expand Up @@ -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,
};
})}
/>
)}
</div>
Expand Down
4 changes: 1 addition & 3 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,7 @@ function StoryHeader({
)}
</div>
</div>
{!storyline.sunset && (
<AddPlotButton storylineId={storyline.storyline_id} writerAddress={storyline.writer_address} />
)}
<AddPlotButton storylineId={storyline.storyline_id} writerAddress={storyline.writer_address} lastPlotTime={storyline.last_plot_time} sunset={storyline.sunset} />
</header>
);
}
Expand Down
25 changes: 25 additions & 0 deletions src/components/AddPlotButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className="border-border text-muted mt-3 block w-full rounded border py-2 text-center text-xs font-medium opacity-50"
title="The 7-day deadline has expired"
>
Deadline expired
</div>
);
}

return (
<Link
href={`/create?tab=chain&storyline=${storylineId}`}
Expand Down
3 changes: 2 additions & 1 deletion src/components/DeadlineCountdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useState, useEffect } from "react";

const DEADLINE_HOURS = 168;
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<number | null>(null);
Expand Down
22 changes: 13 additions & 9 deletions src/components/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}

interface SelectProps {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}
Expand Down
Loading