BondDashboard.tsx (22957B)
1 "use client"; 2 3 import React, { useState, useMemo, useRef, useEffect, useLayoutEffect } from 'react'; 4 import { 5 XAxis, YAxis, CartesianGrid, 6 ResponsiveContainer, AreaChart, Area, Tooltip, Line 7 } from 'recharts'; 8 import { format } from 'date-fns'; 9 import { 10 CHARACTER_LIST, GIFT_LIST, 11 SPRITE_CONFIG, CharacterId, 12 BOND_EXP_TABLE 13 } from '@/lib/bondData'; 14 15 interface BondRecord { 16 id: string; 17 char_key: string; 18 bond_level: number; 19 recorded_at: string; 20 } 21 22 const MIN_CHART_DIMENSION = 32; 23 24 const getLevelFromExp = (exp: number): number => { 25 let currentLevel = 1; 26 for (let lv = 1; lv <= 100; lv++) { 27 if (BOND_EXP_TABLE[lv] !== undefined && exp >= BOND_EXP_TABLE[lv]) { 28 currentLevel = lv; 29 } else { 30 break; 31 } 32 } 33 return currentLevel; 34 }; 35 36 const TrashIcon = () => ( 37 <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> 38 <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> 39 </svg> 40 ); 41 42 const CustomTooltip = ({ active, payload, label, allData }: any) => { 43 if (!active || !payload || !payload.length) return null; 44 45 const sameDayPoints = allData.filter((d: any) => d.timestamp === label); 46 if (sameDayPoints.length === 0) return null; 47 48 const dateStr = sameDayPoints[0].recorded_at; 49 50 const hasActual = sameDayPoints.some((p: any) => !p.isPrediction); 51 const filteredPoints = sameDayPoints.filter((p: any) => { 52 if (hasActual && p.isPrediction) return false; 53 return true; 54 }); 55 56 const sortedPoints = filteredPoints.sort((a: any, b: any) => b.bond_level - a.bond_level); 57 const isAllPrediction = sortedPoints.every((p: any) => p.isPrediction); 58 59 return ( 60 <div 61 className="recharts-default-tooltip" 62 style={{ 63 margin: 0, 64 padding: '10px', 65 backgroundColor: 'var(--card)', 66 border: '1px solid var(--muted)', 67 borderRadius: '0.5rem', 68 fontSize: '0.85rem', 69 whiteSpace: 'nowrap' 70 }} 71 > 72 <p 73 className="recharts-tooltip-label" 74 style={{ 75 margin: '0px 0px 4px', 76 color: 'var(--foreground)', 77 fontWeight: 700 78 }} 79 > 80 {isAllPrediction ? `Estimated Date: ${dateStr}` : `Date: ${dateStr}`} 81 </p> 82 83 <ul className="recharts-tooltip-item-list" style={{ padding: 0, margin: 0, listStyle: 'none' }}> 84 {sortedPoints.map((p: any, idx: number) => ( 85 <li 86 key={idx} 87 className="recharts-tooltip-item" 88 style={{ display: 'block', paddingTop: '4px', paddingBottom: '4px' }} 89 > 90 <span style={{ fontWeight: 700 }}> 91 Rank {p.bond_level} {p.isPrediction ? '' : `(${p.actual_exp} exp)`} 92 </span> 93 </li> 94 ))} 95 </ul> 96 </div> 97 ); 98 }; 99 100 export default function BondDashboard({ 101 bondHistory, 102 onSave, 103 isSyncing 104 }: { 105 bondHistory: BondRecord[], 106 onSave: (level: number, date: string, charKey: string) => Promise<void>, 107 isSyncing?: boolean 108 }) { 109 const [selectedCharId, setSelectedCharId] = useState<CharacterId>(CHARACTER_LIST[0].id); 110 const [isMounted, setIsMounted] = useState(false); 111 112 const containerRef = useRef<HTMLDivElement | null>(null); 113 const [isChartReady, setIsChartReady] = useState(false); 114 115 useEffect(() => { 116 if (typeof window !== 'undefined') { 117 const savedCharId = localStorage.getItem('last_selected_char_id') as CharacterId | null; 118 if (savedCharId && CHARACTER_LIST.some(c => c.id === savedCharId)) { 119 setSelectedCharId(savedCharId); 120 } 121 setIsMounted(true); 122 } 123 }, []); 124 125 useLayoutEffect(() => { 126 const el = containerRef.current; 127 if (!el) return; 128 129 const update = (rect = el.getBoundingClientRect()) => { 130 if (rect.width >= MIN_CHART_DIMENSION && rect.height >= MIN_CHART_DIMENSION) { 131 setIsChartReady(true); 132 } 133 }; 134 135 update(); 136 if (typeof ResizeObserver === "undefined") return; 137 138 const observer = new ResizeObserver((entries) => { 139 for (const entry of entries) { 140 if (entry.target === el) update(entry.contentRect); 141 } 142 }); 143 observer.observe(el); 144 145 return () => observer.disconnect(); 146 }, []); 147 148 const [inputLevel, setInputLevel] = useState(1); 149 const [inputDate, setInputDate] = useState(format(new Date(), 'yyyy-MM-dd')); 150 151 const [isSelectOpen, setIsSelectOpen] = useState(false); 152 const [isDesktop, setIsDesktop] = useState(false); 153 154 const dropdownRef = useRef<HTMLDivElement>(null); 155 156 useEffect(() => { 157 if (isSelectOpen && dropdownRef.current) { 158 setTimeout(() => { 159 const activeItem = dropdownRef.current?.querySelector('.char-dropdown-item.active'); 160 if (activeItem) { 161 activeItem.scrollIntoView({ 162 behavior: 'smooth', 163 block: 'center', 164 }); 165 } 166 }, 20); 167 } 168 }, [isSelectOpen]); 169 170 const selectedChar = useMemo(() => 171 CHARACTER_LIST.find(c => c.id === selectedCharId) || CHARACTER_LIST[0], 172 [selectedCharId]); 173 174 const sortedGifts = useMemo(() => { 175 const ratingOrder: Record<string, number> = { 176 'ss': 1, 'sa': 2, 'ns': 3, 'na': 4, 'nb': 5 177 }; 178 179 return GIFT_LIST 180 .filter(gift => selectedChar.giftRatings[gift.name]) 181 .sort((a, b) => { 182 const ratingA = selectedChar.giftRatings[a.name]?.toLowerCase() || ''; 183 const ratingB = selectedChar.giftRatings[b.name]?.toLowerCase() || ''; 184 const orderA = ratingOrder[ratingA] || 99; 185 const orderB = ratingOrder[ratingB] || 99; 186 return orderA - orderB; 187 }); 188 }, [selectedChar]); 189 190 const filteredHistory = useMemo(() => { 191 return bondHistory 192 .filter(h => h.char_key === selectedCharId) 193 .sort((a, b) => { 194 const dateCompare = b.recorded_at.localeCompare(a.recorded_at); 195 if (dateCompare !== 0) return dateCompare; 196 return b.bond_level - a.bond_level; 197 }); 198 }, [bondHistory, selectedCharId]); 199 200 const displayHistoryRows = useMemo(() => { 201 const rows = []; 202 for (let i = 0; i < 6; i++) { 203 rows.push(filteredHistory[i] || null); 204 } 205 return rows; 206 }, [filteredHistory]); 207 208 const chartDataCombined = useMemo(() => { 209 if (filteredHistory.length === 0) return { data: [], hasPrediction: false }; 210 211 const actualPoints = [...bondHistory] 212 .filter(h => h.char_key === selectedCharId) 213 .sort((a, b) => { 214 const dateCompare = a.recorded_at.localeCompare(b.recorded_at); 215 if (dateCompare !== 0) return dateCompare; 216 return a.bond_level - b.bond_level; 217 }) 218 .map(h => ({ 219 recorded_at: h.recorded_at, 220 timestamp: new Date(h.recorded_at).getTime(), 221 cumulative_exp: BOND_EXP_TABLE[h.bond_level] || 0, 222 bond_level: h.bond_level, 223 isPrediction: false, 224 actual_exp: BOND_EXP_TABLE[h.bond_level] || 0, 225 predicted_exp: null as number | null, 226 })); 227 228 const lastActual = actualPoints[actualPoints.length - 1]; 229 const targetExp = 240225; 230 231 if (lastActual.cumulative_exp >= targetExp || actualPoints.length < 2) { 232 return { data: actualPoints, hasPrediction: false, xDomain: ['dataMin', 'dataMax'] as const }; 233 } 234 235 const n = actualPoints.length; 236 let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; 237 for (const p of actualPoints) { 238 sumX += p.timestamp; 239 sumY += p.cumulative_exp; 240 sumXY += p.timestamp * p.cumulative_exp; 241 sumXX += p.timestamp * p.timestamp; 242 } 243 244 const denominator = n * sumXX - sumX * sumX; 245 if (denominator === 0) { 246 return { data: actualPoints, hasPrediction: false, xDomain: ['dataMin', 'dataMax'] as const }; 247 } 248 249 const slope = (n * sumXY - sumX * sumY) / denominator; 250 const intercept = (sumY - slope * sumX) / n; 251 252 if (slope <= 0) { 253 return { data: actualPoints, hasPrediction: false, xDomain: ['dataMin', 'dataMax'] as const }; 254 } 255 256 const predictionPoints: any[] = []; 257 258 predictionPoints.push({ 259 recorded_at: lastActual.recorded_at, 260 timestamp: lastActual.timestamp, 261 cumulative_exp: lastActual.cumulative_exp, 262 bond_level: lastActual.bond_level, 263 isPrediction: true, 264 actual_exp: null, 265 predicted_exp: lastActual.cumulative_exp 266 }); 267 268 let nextTargetLevel = Math.floor((lastActual.bond_level / 5) + 1) * 5; 269 if (nextTargetLevel === lastActual.bond_level) { 270 nextTargetLevel += 5; 271 } 272 273 for (let lv = nextTargetLevel; lv <= 100; lv += 5) { 274 const expAtLv = BOND_EXP_TABLE[lv]; 275 if (expAtLv === undefined) continue; 276 277 const ts = Math.round((expAtLv - intercept) / slope); 278 279 predictionPoints.push({ 280 recorded_at: format(new Date(ts), 'yyyy-MM-dd'), 281 timestamp: ts, 282 cumulative_exp: expAtLv, 283 bond_level: lv, 284 isPrediction: true, 285 actual_exp: null, 286 predicted_exp: expAtLv 287 }); 288 } 289 290 const finalPoint = predictionPoints[predictionPoints.length - 1]; 291 if (!finalPoint || finalPoint.bond_level !== 100) { 292 const ts100 = Math.round((targetExp - intercept) / slope); 293 predictionPoints.push({ 294 recorded_at: format(new Date(ts100), 'yyyy-MM-dd'), 295 timestamp: ts100, 296 cumulative_exp: targetExp, 297 bond_level: 100, 298 isPrediction: true, 299 actual_exp: null, 300 predicted_exp: targetExp 301 }); 302 } 303 304 return { 305 data: [...actualPoints, ...predictionPoints.slice(0)], 306 hasPrediction: true, 307 xDomain: [actualPoints[0].timestamp, predictionPoints[predictionPoints.length - 1].timestamp] as const 308 }; 309 }, [filteredHistory]); 310 311 const graphData = chartDataCombined.data; 312 313 useEffect(() => { 314 if (!isMounted) return; 315 localStorage.setItem('last_selected_char_id', selectedCharId); 316 const charHistory = bondHistory 317 .filter(h => h.char_key === selectedCharId) 318 .sort((a, b) => { 319 const dateCompare = b.recorded_at.localeCompare(a.recorded_at); 320 if (dateCompare !== 0) return dateCompare; 321 return b.bond_level - a.bond_level; 322 }); 323 324 if (charHistory.length > 0) { 325 setInputLevel(Math.min(100, charHistory[0].bond_level + 1)); 326 } else { 327 setInputLevel(1); 328 } 329 }, [selectedCharId, bondHistory, isMounted]); 330 331 useEffect(() => { 332 const handleResize = () => { 333 setIsDesktop(window.innerWidth >= 1000); 334 }; 335 handleResize(); 336 window.addEventListener('resize', handleResize); 337 return () => window.removeEventListener('resize', handleResize); 338 }, []); 339 340 useEffect(() => { 341 const handleClickOutside = (event: MouseEvent) => { 342 if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 343 setIsSelectOpen(false); 344 } 345 }; 346 document.addEventListener('mousedown', handleClickOutside); 347 return () => document.removeEventListener('mousedown', handleClickOutside); 348 }, []); 349 350 const handleSave = async () => { 351 await onSave(inputLevel, inputDate, selectedCharId); 352 }; 353 354 const handleDelete = async (recordId: string) => { 355 if (!window.confirm("この記録を削除してもよろしいですか?")) return; 356 357 try { 358 let token = ''; 359 if (typeof window !== 'undefined') { 360 const authKey = Object.keys(localStorage).find(key => key.startsWith('sb-') && key.endsWith('-auth-token')); 361 if (authKey) { 362 const authDataString = localStorage.getItem(authKey); 363 if (authDataString) { 364 const authData = JSON.parse(authDataString); 365 token = authData?.access_token || ''; 366 } 367 } 368 } 369 370 const res = await fetch('/api/relationship', { 371 method: 'DELETE', 372 headers: { 373 'Content-Type': 'application/json', 374 ...(token ? { 'Authorization': `Bearer ${token}` } : {}), 375 }, 376 body: JSON.stringify({ id: recordId }), 377 }); 378 379 if (!res.ok) throw new Error('Failed to delete'); 380 381 window.location.reload(); 382 } catch (err) { 383 console.error(err); 384 alert("削除に失敗しました。"); 385 } 386 }; 387 388 const renderGraph = ( 389 <div 390 ref={containerRef} 391 className={`graph-container ${!isChartReady ? 'opacity-0 pointer-events-none' : ''}`} 392 data-chart-ready={isChartReady ? "true" : "false"} 393 > 394 {isChartReady && ( 395 graphData.length > 0 ? ( 396 <ResponsiveContainer width="100%" height="100%"> 397 <AreaChart data={graphData} margin={{ top: 5, right: 5, left: -35, bottom: -10 }}> 398 <defs> 399 <linearGradient id="colorExp" x1="0" y1="0" x2="0" y2="1"> 400 <stop offset="5%" stopColor="var(--primary)" stopOpacity={0.4}/> 401 <stop offset="60%" stopColor="var(--primary)" stopOpacity={0.2}/> 402 <stop offset="95%" stopColor="var(--primary)" stopOpacity={0}/> 403 </linearGradient> 404 </defs> 405 <CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--secondary)" opacity={0.2} /> 406 <XAxis 407 dataKey="timestamp" 408 type="number" 409 domain={chartDataCombined.xDomain || ['dataMin', 'dataMax']} 410 tickLine={true} 411 axisLine={true} 412 stroke="var(--secondary-foreground)" 413 fontSize={10} 414 tickCount={14} 415 tickFormatter={(ts) => { 416 try { 417 const d = new Date(ts); 418 const y = d.getFullYear(); 419 const m = String(d.getMonth() + 1).padStart(2, '0'); 420 return `${y}/${m}`; 421 } catch (e) {} 422 return ''; 423 }} 424 /> 425 <YAxis 426 dataKey="cumulative_exp" 427 domain={[0, 240225]} 428 ticks={[0, 14790, 29175, 50835, 81270, 121980, 174465, 240225]} 429 tickFormatter={(exp) => `${getLevelFromExp(exp)}`} 430 stroke="var(--secondary-foreground)" 431 fontSize={10} 432 tickLine={true} 433 axisLine={true} 434 /> 435 <Tooltip 436 content={<CustomTooltip allData={graphData} />} 437 trigger="hover" 438 shared={true} 439 /> 440 <Area 441 type="monotone" 442 dataKey="actual_exp" 443 stroke="var(--primary)" 444 strokeWidth={3} 445 fillOpacity={1} 446 fill="url(#colorExp)" 447 connectNulls={false} 448 /> 449 <Line 450 type="monotone" 451 dataKey="predicted_exp" 452 stroke="var(--primary)" 453 strokeWidth={2} 454 strokeDasharray="5 5" 455 dot={false} 456 activeDot={false} 457 connectNulls={true} 458 /> 459 </AreaChart> 460 </ResponsiveContainer> 461 ) : ( 462 <div className="flex h-full w-full items-center justify-center border border-dashed border-(--muted) rounded-2xl bg-(--card)/30"> 463 <span className="text-lg font-bold text-(--muted-foreground) tracking-wider opacity-60"> 464 No Data 465 </span> 466 </div> 467 ) 468 )} 469 </div> 470 ); 471 472 return ( 473 <div className="bond-container"> 474 <div className="bond-split-layout"> 475 <div className="bond-left-panel"> 476 <div className="char-selector" ref={dropdownRef}> 477 <button 478 className="char-selector-trigger" 479 onClick={() => setIsSelectOpen(!isSelectOpen)} 480 > 481 <span className="char-selector-label">Student</span> 482 <h1 className="char-selector-name"> 483 {selectedChar.name} 484 <span className={`char-selector-arrow ${isSelectOpen ? 'open' : ''}`}> 485 ▼ 486 </span> 487 </h1> 488 </button> 489 490 {isSelectOpen && ( 491 <div className="char-dropdown scrollbar-hide animate-in fade-in zoom-in-95 duration-200"> 492 <div className="char-dropdown-container"> 493 {CHARACTER_LIST.map((char) => ( 494 <button 495 key={char.id} 496 onClick={() => { 497 setSelectedCharId(char.id as CharacterId); 498 setIsSelectOpen(false); 499 }} 500 className={`char-dropdown-item ${selectedCharId === char.id ? 'active' : ''}`} 501 > 502 {char.name} 503 </button> 504 ))} 505 </div> 506 </div> 507 )} 508 </div> 509 510 <div className="record-input-area"> 511 <div className="input-group-container"> 512 {/* Rank フィールド */} 513 <div className="input-field-wrapper rank-field"> 514 <label className="input-label">Rank</label> 515 <div className="input-content"> 516 <span className="rank-value">{inputLevel}</span> 517 <div className="spin-buttons"> 518 <button 519 onClick={() => setInputLevel(prev => Math.min(100, prev + 1))} 520 className="spin-btn" 521 > 522 ▲ 523 </button> 524 <button 525 onClick={() => setInputLevel(prev => Math.max(1, prev - 1))} 526 className="spin-btn" 527 > 528 ▼ 529 </button> 530 </div> 531 </div> 532 </div> 533 534 {/* Date フィールド */} 535 <div className="input-field-wrapper date-field"> 536 <label className="input-label">Date</label> 537 <div className="input-content"> 538 <input 539 type="date" 540 value={inputDate} 541 onChange={(e) => setInputDate(e.target.value)} 542 className="date-input-display" 543 /> 544 </div> 545 </div> 546 547 {/* Record ボタンを同じコンテナ内に並列で配置 */} 548 <button onClick={handleSave} disabled={isSyncing} className="btn-bond-record"> 549 <span>Record</span> 550 <div>+</div> 551 </button> 552 </div> 553 </div> 554 555 <div className="section-wrapper"> 556 <h3 className="gift-title">Favorite Gifts</h3> 557 <div className="gift-scroll-container scrollbar-hide"> 558 {sortedGifts.map((gift) => { 559 const rating = selectedChar.giftRatings[gift.name]; 560 const rankChar = rating?.slice(-1).toUpperCase(); 561 const row = Math.floor(gift.spriteIdx / SPRITE_CONFIG.cols); 562 const col = gift.spriteIdx % SPRITE_CONFIG.cols; 563 564 return ( 565 <div key={gift.name} className="gift-item"> 566 <div className="gift-image-box"> 567 <div 568 className="gift-sprite" 569 style={{ '--col': col, '--row': row } as React.CSSProperties} 570 /> 571 <div className={`gift-rank-dot ${ 572 rankChar === 'S' ? 's' : rankChar === 'A' ? 'a' : 'b' 573 }`} /> 574 </div> 575 <span className="gift-category-text"> 576 {gift.category || "GIFT"} 577 </span> 578 </div> 579 ); 580 })} 581 </div> 582 </div> 583 584 <div className="section-wrapper"> 585 <h3 className="section-toggle-btn" style={{ cursor: 'default' }}> 586 <span>History Log</span> 587 </h3> 588 <div className="history-grid-container"> 589 {displayHistoryRows.map((record, index) => ( 590 <div key={record ? record.id : `empty-${index}`} className="history-item"> 591 <div className="history-log-row"> 592 {record ? ( 593 <> 594 <span className="history-rank-num"> 595 {record.bond_level} 596 </span> 597 <span className="history-divider">|</span> 598 <span className="history-date-text"> 599 {record.recorded_at} 600 </span> 601 </> 602 ) : ( 603 <span className="history-empty-text"> 604 <span className="history-rank-num"> 605 --- 606 </span> 607 <span className="history-divider">|</span> 608 <span className="history-date-text"> 609 ---- -- -- 610 </span> 611 </span> 612 )} 613 </div> 614 615 {record ? ( 616 <button 617 onClick={() => handleDelete(record.id)} 618 className="edit-icon-btn text-rose-500 hover:text-rose-700 hover:bg-rose-500/10" 619 aria-label="Delete record" 620 > 621 <TrashIcon /> 622 </button> 623 ) : ( 624 <span className="edit-btn-placeholder" /> 625 )} 626 </div> 627 ))} 628 </div> 629 </div> 630 631 {!isDesktop && ( 632 <div className="section-wrapper"> 633 <h3 className="section-toggle-btn" style={{ cursor: 'default' }}> 634 <span>Progress Graph</span> 635 </h3> 636 <div className="animate-in fade-in duration-200"> 637 {renderGraph} 638 </div> 639 </div> 640 )} 641 </div> 642 {isDesktop && ( 643 <div className="bond-right-panel animate-in fade-in duration-300"> 644 <h3 className="section-toggle-btn" style={{ cursor: 'default' }}>Progress Graph</h3> 645 {renderGraph} 646 </div> 647 )} 648 </div> 649 </div> 650 ); 651 } 652 653 if (typeof window !== "undefined") { 654 const filterWarning = (args: any[], originalFn: (...args: any[]) => void) => { 655 if ( 656 typeof args[0] === "string" && 657 args[0].includes("should be greater than 0") 658 ) { 659 return; 660 } 661 originalFn(...args); 662 }; 663 664 const originalConsoleError = console.error; 665 console.error = (...args: any[]) => filterWarning(args, originalConsoleError); 666 const originalConsoleWarn = console.warn; 667 console.warn = (...args: any[]) => filterWarning(args, originalConsoleWarn); 668 }