feat: 메인화면 배경 생성
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,3 +40,4 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.idea
|
||||
.gemini
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
// app/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "QuietSprint",
|
||||
template: "%s · QuietSprint",
|
||||
},
|
||||
description: "One goal. Start now.",
|
||||
title: "Focustella",
|
||||
description: "Space-themed focus timer",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="ko" className="dark">
|
||||
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans selection:bg-indigo-500/30 overflow-x-hidden">
|
||||
{/* Layout Container */}
|
||||
<div className="relative z-10 min-h-screen flex flex-col max-w-6xl mx-auto">
|
||||
<header className="flex items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="font-bold text-lg tracking-wider text-indigo-400 hover:text-indigo-300 transition-colors z-50">
|
||||
FOCUSTELLA
|
||||
</Link>
|
||||
<nav className="flex gap-4 text-sm font-medium text-slate-400 z-50">
|
||||
<Link href="/log" className="hover:text-slate-200 transition-colors">항해일지</Link>
|
||||
<Link href="/settings" className="hover:text-slate-200 transition-colors">설정</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 relative flex flex-col w-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
291
src/app/page.tsx
291
src/app/page.tsx
@@ -1,266 +1,65 @@
|
||||
// app/page.tsx
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentVoyage } from "@/lib/store";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import LobbyBackground from "@/components/LobbyBackground";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
||||
|
||||
const PRIMARY = "#2F6FED";
|
||||
const PRIMARY_HOVER = "#295FD1";
|
||||
|
||||
function modeLabel(mode: Mode) {
|
||||
switch (mode) {
|
||||
case "freeflow":
|
||||
return "프리플로우";
|
||||
case "sprint":
|
||||
return "스프린트";
|
||||
case "deepwork":
|
||||
return "딥워크";
|
||||
}
|
||||
}
|
||||
|
||||
function modeMeta(mode: Mode) {
|
||||
if (mode === "freeflow") return "무제한";
|
||||
if (mode === "sprint") return "25분";
|
||||
return "90분";
|
||||
}
|
||||
|
||||
function startLabel(mode: Mode) {
|
||||
if (mode === "freeflow") return "집중 시작";
|
||||
return `집중 시작 (${modeMeta(mode)})`;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [isMount, setIsMount] = useState(false);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<Mode | null>(null);
|
||||
const [goal, setGoal] = useState("");
|
||||
useEffect(() => {
|
||||
setIsMount(true);
|
||||
const current = getCurrentVoyage();
|
||||
if (current && current.status === 'in_progress') {
|
||||
router.replace('/flight');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const meta = useMemo(() => (mode ? modeMeta(mode) : ""), [mode]);
|
||||
|
||||
const go = useCallback(
|
||||
(m: Mode, g?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("mode", m);
|
||||
|
||||
if (g && g.trim())
|
||||
localStorage.setItem("hushroom:session-goal", g.trim());
|
||||
else localStorage.removeItem("hushroom:session-goal");
|
||||
|
||||
// nextAction은 사용하지 않음
|
||||
localStorage.removeItem("hushroom:session-nextAction");
|
||||
|
||||
localStorage.setItem("hushroom:last-mode", m);
|
||||
router.push(`/session?${params.toString()}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const openDialog = (m: Mode) => {
|
||||
setMode(m);
|
||||
setGoal("");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!mode) return;
|
||||
setOpen(false);
|
||||
go(mode, goal);
|
||||
};
|
||||
if (!isMount) return null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full bg-[#E9EEF6]">
|
||||
<header className="px-5 pt-6">
|
||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||
QuietSprint
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-600">
|
||||
딱 한 가지 목표. 바로 시작.
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col flex-1 relative animate-in fade-in duration-500">
|
||||
{/* Background Layer */}
|
||||
<LobbyBackground />
|
||||
|
||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
|
||||
{/* ✅ 파란 CTA = 프리플로우 */}
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-semibold text-slate-600">자유 세션</div>
|
||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||
시간 제한 없이, 원할 때 종료 (60분마다 가볍게 노크)
|
||||
</div>
|
||||
{/* Content Layer */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-6 md:p-12">
|
||||
<div className="space-y-4 text-center mb-12 max-w-2xl mx-auto backdrop-blur-sm bg-slate-950/30 p-6 rounded-3xl border border-slate-800/30">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-slate-100 tracking-tight">어느 별자리로 출항할까요?</h1>
|
||||
<p className="text-slate-300 text-lg">몰입하기 좋은 궤도입니다.</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => openDialog("freeflow")}
|
||||
className="h-auto w-full items-start justify-start whitespace-normal rounded-3xl bg-[#2F6FED]
|
||||
px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
|
||||
<div className="grid gap-6 w-full max-w-5xl grid-cols-1 md:grid-cols-2 lg:grid-cols-3 place-items-stretch">
|
||||
{ROUTES.map((route) => (
|
||||
<div key={route.id} className="group relative bg-slate-900/60 backdrop-blur-md hover:bg-slate-800/80 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-6 transition-all duration-300 flex flex-col h-full shadow-lg hover:shadow-indigo-900/20">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-xl text-indigo-100 group-hover:text-white transition-colors">{route.name}</h3>
|
||||
<span className="text-xs font-medium text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded-full border border-slate-800 mt-1 inline-block">
|
||||
{route.tag}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-3xl font-light text-slate-300 group-hover:text-white transition-colors">{route.durationMinutes}<span className="text-sm ml-1 text-slate-500">min</span></span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mb-8 flex-1 leading-relaxed">{route.description}</p>
|
||||
<Link
|
||||
href={`/boarding?routeId=${route.id}`}
|
||||
className="flex w-full items-center justify-center py-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/30 active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="text-2xl font-semibold leading-none">
|
||||
프리플로우
|
||||
바로 출항
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 text-lg opacity-90">무제한</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="my-8 flex items-center gap-3">
|
||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
||||
<div className="text-sm font-semibold text-slate-600">몰입 블록</div>
|
||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ✅ 버튼 위에 설명 (버튼 안에 설명 X) */}
|
||||
<div className="-mt-2 mb-5">
|
||||
<div className="text-sm font-semibold text-slate-600">
|
||||
시간 고정 세션
|
||||
</div>
|
||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||
한 번 실행되고 끝나면 요약으로 이동
|
||||
<div className="mt-16 text-center py-6 text-xs text-slate-500 font-medium tracking-wide uppercase">
|
||||
정거장에서 3명이 대기 중
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ row(2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ModeTile
|
||||
title="스프린트"
|
||||
meta="25분"
|
||||
onClick={() => openDialog("sprint")}
|
||||
/>
|
||||
<ModeTile
|
||||
title="딥워크"
|
||||
meta="90분"
|
||||
onClick={() => openDialog("deepwork")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SessionGoalDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
mode={mode}
|
||||
meta={meta}
|
||||
goal={goal}
|
||||
setGoal={setGoal}
|
||||
onStart={start}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeTile({
|
||||
title,
|
||||
meta,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
meta: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="rounded-3xl border border-[#C9D7F5] bg-white shadow-sm transition hover:bg-[#F1F5FF]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className="h-auto w-full rounded-3xl px-7 py-5 text-left active:scale-[0.99] hover:bg-transparent"
|
||||
>
|
||||
<div className="flex w-full items-baseline justify-between">
|
||||
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
||||
<div className="text-lg font-semibold text-blue-700">{meta}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionGoalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
meta,
|
||||
goal,
|
||||
setGoal,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
mode: Mode | null;
|
||||
meta: string;
|
||||
goal: string;
|
||||
setGoal: (v: string) => void;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const title = mode ? `${modeLabel(mode)} · ${meta}` : "세션";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md rounded-2xl">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onStart();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">세션 목표 설정</DialogTitle>
|
||||
<div className="text-sm text-slate-600">{title}</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Input
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="지금 할 한 가지를 한 줄로 적어주세요 (선택)"
|
||||
className="text-lg focus-visible:ring-2"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.nativeEvent as any).isComposing) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-slate-500">
|
||||
짧게 적을수록 좋아요. 끝이 보이게.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl"
|
||||
style={{ backgroundColor: PRIMARY }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
||||
}
|
||||
>
|
||||
{mode ? startLabel(mode) : "집중 시작"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
59
src/components/LobbyBackground.tsx
Normal file
59
src/components/LobbyBackground.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
export default function LobbyBackground() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
|
||||
{/* Orion (Approximate) - Bottom Left */}
|
||||
<div className="absolute bottom-20 left-10 w-80 h-80 opacity-20 rotate-[-15deg]">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full text-indigo-200 fill-current">
|
||||
{/* Stars */}
|
||||
<circle cx="20" cy="10" r="1.5" className="animate-twinkle" /> {/* Betelgeuse */}
|
||||
<circle cx="80" cy="25" r="1.5" className="animate-twinkle-delay-1" /> {/* Rigel */}
|
||||
<circle cx="25" cy="85" r="1.2" className="animate-twinkle-delay-2" /> {/* Saiph */}
|
||||
<circle cx="75" cy="80" r="1.2" className="animate-twinkle" /> {/* Bellatrix */}
|
||||
|
||||
{/* Belt */}
|
||||
<circle cx="45" cy="50" r="1" className="animate-twinkle-delay-1" />
|
||||
<circle cx="50" cy="48" r="1" className="animate-twinkle" />
|
||||
<circle cx="55" cy="46" r="1" className="animate-twinkle-delay-2" />
|
||||
|
||||
{/* Lines */}
|
||||
<line x1="20" y1="10" x2="45" y2="50" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="80" y1="25" x2="55" y2="46" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="25" y1="85" x2="45" y2="50" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="75" y1="80" x2="55" y2="46" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lyra (Approximate) - Top Right */}
|
||||
<div className="absolute top-20 right-20 w-64 h-64 opacity-20 rotate-[10deg]">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full text-blue-200 fill-current">
|
||||
<circle cx="50" cy="10" r="1.8" className="animate-twinkle" /> {/* Vega */}
|
||||
<circle cx="30" cy="30" r="1" className="animate-twinkle-delay-1" />
|
||||
<circle cx="70" cy="30" r="1" className="animate-twinkle-delay-2" />
|
||||
<circle cx="35" cy="60" r="1" className="animate-twinkle" />
|
||||
<circle cx="65" cy="60" r="1" className="animate-twinkle-delay-1" />
|
||||
|
||||
<line x1="50" y1="10" x2="30" y2="30" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="50" y1="10" x2="70" y2="30" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="30" y1="30" x2="35" y2="60" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="70" y1="30" x2="65" y2="60" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
<line x1="35" y1="60" x2="65" y2="60" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Ursa Major (Big Dipper) - Top Left */}
|
||||
<div className="absolute top-10 left-10 md:left-40 w-96 h-64 opacity-15 rotate-[20deg]">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full text-white fill-current">
|
||||
<circle cx="10" cy="30" r="1.2" className="animate-twinkle-delay-2" />
|
||||
<circle cx="30" cy="25" r="1.2" className="animate-twinkle" />
|
||||
<circle cx="45" cy="35" r="1.2" className="animate-twinkle-delay-1" />
|
||||
<circle cx="60" cy="40" r="1.2" className="animate-twinkle-delay-2" />
|
||||
<circle cx="70" cy="60" r="1.2" className="animate-twinkle" />
|
||||
<circle cx="90" cy="60" r="1.2" className="animate-twinkle-delay-1" />
|
||||
<circle cx="90" cy="40" r="1.2" className="animate-twinkle-delay-2" />
|
||||
|
||||
<polyline points="10,30 30,25 45,35 60,40 70,60 90,60 90,40 60,40" fill="none" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/lib/constants.ts
Normal file
25
src/lib/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Route } from "@/types";
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
id: 'orion',
|
||||
name: '오리온',
|
||||
durationMinutes: 180,
|
||||
tag: '딥워크',
|
||||
description: '집필, 코딩 등 긴 호흡이 필요한 작업'
|
||||
},
|
||||
{
|
||||
id: 'lyra',
|
||||
name: '거문고',
|
||||
durationMinutes: 60,
|
||||
tag: '정리/기획',
|
||||
description: '기획안 작성, 문서 정리'
|
||||
},
|
||||
{
|
||||
id: 'cygnus',
|
||||
name: '백조',
|
||||
durationMinutes: 30,
|
||||
tag: '리뷰/회고',
|
||||
description: '하루 회고, 코드 리뷰'
|
||||
},
|
||||
];
|
||||
43
src/lib/store.ts
Normal file
43
src/lib/store.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Voyage, UserPreferences } from "@/types";
|
||||
|
||||
const KEYS = {
|
||||
HISTORY: 'focustella_history_v1',
|
||||
CURRENT: 'focustella_current_v1',
|
||||
PREFS: 'focustella_prefs_v1',
|
||||
};
|
||||
|
||||
export const getHistory = (): Voyage[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const item = localStorage.getItem(KEYS.HISTORY);
|
||||
return item ? JSON.parse(item) : [];
|
||||
};
|
||||
|
||||
export const saveToHistory = (voyage: Voyage) => {
|
||||
const history = getHistory();
|
||||
// Add to beginning
|
||||
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
|
||||
};
|
||||
|
||||
export const getCurrentVoyage = (): Voyage | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const item = localStorage.getItem(KEYS.CURRENT);
|
||||
return item ? JSON.parse(item) : null;
|
||||
};
|
||||
|
||||
export const saveCurrentVoyage = (voyage: Voyage | null) => {
|
||||
if (voyage === null) {
|
||||
localStorage.removeItem(KEYS.CURRENT);
|
||||
} else {
|
||||
localStorage.setItem(KEYS.CURRENT, JSON.stringify(voyage));
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreferences = (): UserPreferences => {
|
||||
if (typeof window === 'undefined') return { hideSeconds: false };
|
||||
const item = localStorage.getItem(KEYS.PREFS);
|
||||
return item ? JSON.parse(item) : { hideSeconds: false };
|
||||
};
|
||||
|
||||
export const savePreferences = (prefs: UserPreferences) => {
|
||||
localStorage.setItem(KEYS.PREFS, JSON.stringify(prefs));
|
||||
};
|
||||
28
src/types/index.ts
Normal file
28
src/types/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
tag: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type VoyageStatus = 'completed' | 'partial' | 'reoriented' | 'aborted' | 'in_progress';
|
||||
|
||||
export interface Voyage {
|
||||
id: string;
|
||||
routeId: string;
|
||||
routeName: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
durationMinutes: number;
|
||||
status: VoyageStatus;
|
||||
missionText: string;
|
||||
notes?: string;
|
||||
debriefProgress?: string;
|
||||
nextAction?: string;
|
||||
blockerTag?: string;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
hideSeconds: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user