From 2e3c2debadbb32ec72a282735c397ebe0e836e73 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 18:41:03 +0900 Subject: [PATCH] [#940] Redesign streak card with weekly calendar and boost tiers - Lightning bolt header with streak count and current boost % - Weekly calendar row with 7 day dots (checked, fire, pending, missed, future) - Check In button with SIWE signature, disabled after daily check-in - Boost tiers table showing all 5 tiers with unlock/current status - Explanation text for boost mechanics and tier drop penalty - PlotLink brown/cream palette throughout - Added lastCheckin field to streak type for calendar computation Fixes #940 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/components/airdrop/StreakCard.tsx | 219 ++++++++++++++++++++++---- src/components/airdrop/UserPoints.tsx | 1 + 3 files changed, 188 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index e9ea6dbc..5ab6f664 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.39", + "version": "0.1.40", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/StreakCard.tsx b/src/components/airdrop/StreakCard.tsx index 2a6031ec..6369e64a 100644 --- a/src/components/airdrop/StreakCard.tsx +++ b/src/components/airdrop/StreakCard.tsx @@ -1,16 +1,86 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useAccount, useSignMessage } from "wagmi"; import { useQueryClient } from "@tanstack/react-query"; +/* ─── Types ─── */ + interface StreakData { currentStreak: number; boostPercent: number; nextTier: { days: number; boost: number } | null; checkedInToday: boolean; + lastCheckin: string | null; +} + +/* ─── Boost tier definitions ─── */ + +const BOOST_TIERS = [ + { days: 7, boost: 5 }, + { days: 14, boost: 10 }, + { days: 30, boost: 20 }, + { days: 50, boost: 30 }, + { days: 100, boost: 50 }, +] as const; + +/* ─── Weekly calendar helpers ─── */ + +const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const; + +type DayStatus = "checked" | "today-checked" | "today-pending" | "missed" | "future"; + +function getWeekDays( + currentStreak: number, + checkedInToday: boolean, + lastCheckin: string | null, +): DayStatus[] { + const now = new Date(); + // Get Monday of the current week (UTC) + const todayDow = now.getUTCDay(); // 0=Sun, 1=Mon, ... + const mondayOffset = todayDow === 0 ? -6 : 1 - todayDow; + const monday = new Date(now); + monday.setUTCDate(monday.getUTCDate() + mondayOffset); + monday.setUTCHours(0, 0, 0, 0); + + const todayStr = now.toISOString().slice(0, 10); + const lastCheckinStr = lastCheckin ? new Date(lastCheckin).toISOString().slice(0, 10) : null; + + // Build set of checked-in dates this week by walking back from lastCheckin + const checkedDates = new Set(); + if (lastCheckinStr && currentStreak > 0) { + const lastDate = new Date(lastCheckinStr + "T00:00:00Z"); + for (let i = 0; i < currentStreak; i++) { + const d = new Date(lastDate); + d.setUTCDate(d.getUTCDate() - i); + const ds = d.toISOString().slice(0, 10); + // Only include dates in this week + if (d >= monday) checkedDates.add(ds); + else break; // no need to go further back + } + } + + const result: DayStatus[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(monday); + d.setUTCDate(d.getUTCDate() + i); + const ds = d.toISOString().slice(0, 10); + + if (ds === todayStr) { + result.push(checkedInToday ? "today-checked" : "today-pending"); + } else if (ds > todayStr) { + result.push("future"); + } else if (checkedDates.has(ds)) { + result.push("checked"); + } else { + result.push("missed"); + } + } + return result; } +/* ─── Component ─── */ + export function StreakCard({ streak, address }: { streak: StreakData; address: string }) { const { isConnected } = useAccount(); const { signMessageAsync } = useSignMessage(); @@ -47,50 +117,133 @@ export function StreakCard({ streak, address }: { streak: StreakData; address: s } }; - const progressToNext = streak.nextTier - ? Math.min(100, (streak.currentStreak / streak.nextTier.days) * 100) - : 100; + const weekDays = useMemo( + () => getWeekDays(streak.currentStreak, streak.checkedInToday, streak.lastCheckin), + [streak.currentStreak, streak.checkedInToday, streak.lastCheckin], + ); + + // Current tier index for highlight + const currentTierIdx = useMemo(() => { + for (let i = BOOST_TIERS.length - 1; i >= 0; i--) { + if (streak.currentStreak >= BOOST_TIERS[i].days) return i; + } + return -1; + }, [streak.currentStreak]); return ( -
-
-
- - Streak: {streak.currentStreak} days - - {streak.boostPercent > 0 && ( - +{streak.boostPercent}% boost - )} +
+ {/* Header: streak count + boost */} +
+
+ ⚡ {streak.currentStreak} Day{streak.currentStreak !== 1 ? "s" : ""} Streak
+ {streak.boostPercent > 0 && ( +
+ Current boost: +{streak.boostPercent}% +
+ )} +
+ + {/* Weekly calendar */} +
+
+ {DAY_LABELS.map((label) => ( +
+ {label} +
+ ))} + {weekDays.map((status, i) => ( +
+ +
+ ))} +
+
+ + {/* Check In button */} +
- {streak.nextTier && ( - <> -
-
-
-
- Next tier: {streak.nextTier.days} days (+{streak.nextTier.boost * 100}%) - · {streak.currentStreak}/{streak.nextTier.days} -
- - )} - {!streak.nextTier && ( -
Max streak tier reached
- )} + {error &&
{error}
} - {error &&
{error}
} + {/* Boost tiers table */} +
+
+ Boost Tiers +
+
+ {BOOST_TIERS.map((tier, i) => { + const unlocked = streak.currentStreak >= tier.days; + const isCurrent = i === currentTierIdx; + return ( +
+ {tier.days}+ days + +{tier.boost}% + + {isCurrent ? ( + ← current + ) : unlocked ? ( + ✓ unlocked + ) : null} + +
+ ); + })} +
+

+ Boost applies to all PL point earnings. Miss a day? Drop one tier (not full reset). +

+
); } + +/* ─── Day dot sub-component ─── */ + +function DayDot({ status }: { status: DayStatus }) { + const base = "w-6 h-6 rounded-full flex items-center justify-center text-[10px]"; + + switch (status) { + case "checked": + return ( +
+ ✓ +
+ ); + case "today-checked": + return ( +
+ 🔥 +
+ ); + case "today-pending": + return ( +
+ ); + case "missed": + return ( +
+ ); + case "future": + return ( +
+ ); + } +} diff --git a/src/components/airdrop/UserPoints.tsx b/src/components/airdrop/UserPoints.tsx index bb5e31ec..98424991 100644 --- a/src/components/airdrop/UserPoints.tsx +++ b/src/components/airdrop/UserPoints.tsx @@ -18,6 +18,7 @@ interface PointsData { boostPercent: number; nextTier: { days: number; boost: number } | null; checkedInToday: boolean; + lastCheckin: string | null; }; referral: { code: string | null;