feat(web): snooze suggestion card on Dashboard, event countdown large display
- Dashboard: adaptive snooze suggestion cards (dismissable, max 2 shown, pattern-based) - TimerCard: event countdown type gets large days display with milestone progress bar - Progress bar color-codes: accent → warning (70%) → danger (90%) - Shows days + hours remaining with target date - Updated roadmap: marked snooze suggestion card and event visual as completed - 373 tests across 16 files, tsc clean
This commit is contained in:
parent
f61483e7a5
commit
d909830fcd
@ -381,7 +381,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
|
|||||||
- [x] After 5+ data points: "You always snooze your morning alarm 3 times. Set it 15 minutes later?"
|
- [x] After 5+ data points: "You always snooze your morning alarm 3 times. Set it 15 minutes later?"
|
||||||
- [x] Label normalization for pattern grouping (e.g. "Meeting with Bob" → "meeting with")
|
- [x] Label normalization for pattern grouping (e.g. "Meeting with Bob" → "meeting with")
|
||||||
- [x] checkForSnoozeSuggestion() for real-time suggestions on timer creation
|
- [x] checkForSnoozeSuggestion() for real-time suggestions on timer creation
|
||||||
- [ ] Suggestion shown as dismissable card on dashboard
|
- [x] Suggestion shown as dismissable card on dashboard
|
||||||
- [ ] Accept → auto-adjusts recurring timer
|
- [ ] Accept → auto-adjusts recurring timer
|
||||||
- [x] Unit tests (22 tests)
|
- [x] Unit tests (22 tests)
|
||||||
|
|
||||||
@ -390,7 +390,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
|
|||||||
- [x] Timer type `event` in data model + createEvent factory + store addEvent
|
- [x] Timer type `event` in data model + createEvent factory + store addEvent
|
||||||
- [x] Event tab in CreateTimerModal with date picker + days-remaining preview
|
- [x] Event tab in CreateTimerModal with date picker + days-remaining preview
|
||||||
- [x] Use cases: "132 days until wedding", "47 days until vacation"
|
- [x] Use cases: "132 days until wedding", "47 days until vacation"
|
||||||
- [ ] Visual: large countdown display with milestone progress bar
|
- [x] Visual: large days display with milestone progress bar (color-coded: accent → warning → danger)
|
||||||
|
|
||||||
- [x] **Timer export / import (local backup) — `lib/export.ts` + History page**
|
- [x] **Timer export / import (local backup) — `lib/export.ts` + History page**
|
||||||
- [x] "Export timers" → JSON download of all timers
|
- [x] "Export timers" → JSON download of all timers
|
||||||
|
|||||||
@ -17,12 +17,16 @@ import Link from 'next/link';
|
|||||||
import { FeedbackButton } from './FeedbackButton';
|
import { FeedbackButton } from './FeedbackButton';
|
||||||
import { InstallPrompt } from './InstallPrompt';
|
import { InstallPrompt } from './InstallPrompt';
|
||||||
import { useTheme } from '@/lib/use-theme';
|
import { useTheme } from '@/lib/use-theme';
|
||||||
|
import { getSnoozeSuggestions } from '@/lib/adaptive-snooze';
|
||||||
|
import type { SnoozeSuggestion } from '@/lib/adaptive-snooze';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
const [filterCategory, setFilterCategory] = useState<string | null>(null);
|
||||||
|
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<string>>(new Set());
|
||||||
|
const [snoozeSuggestions, setSnoozeSuggestions] = useState<SnoozeSuggestion[]>([]);
|
||||||
const timers = useTimerStore((s) => s.timers);
|
const timers = useTimerStore((s) => s.timers);
|
||||||
const now = useTimerStore((s) => s.now);
|
const now = useTimerStore((s) => s.now);
|
||||||
const { pause, resume } = useTimerStore();
|
const { pause, resume } = useTimerStore();
|
||||||
@ -56,6 +60,7 @@ export function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
requestNotificationPermission();
|
requestNotificationPermission();
|
||||||
|
setSnoozeSuggestions(getSnoozeSuggestions());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@ -267,6 +272,41 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Snooze suggestions */}
|
||||||
|
{snoozeSuggestions.filter((s) => !dismissedSuggestions.has(s.labelPattern)).length > 0 && (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
{snoozeSuggestions
|
||||||
|
.filter((s) => !dismissedSuggestions.has(s.labelPattern))
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((suggestion) => (
|
||||||
|
<div
|
||||||
|
key={suggestion.labelPattern}
|
||||||
|
className="rounded-xl border p-3 flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(90,140,255,0.08)',
|
||||||
|
borderColor: 'rgba(90,140,255,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate" style={{ color: 'var(--cm-accent)' }}>
|
||||||
|
Snooze pattern detected
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
|
{suggestion.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissedSuggestions((prev) => new Set([...prev, suggestion.labelPattern]))}
|
||||||
|
className="ml-3 text-xs px-2 py-1 rounded-lg cursor-pointer shrink-0"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active timers */}
|
{/* Active timers */}
|
||||||
{activeTimers.length > 0 ? (
|
{activeTimers.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@ -130,7 +130,52 @@ export function TimerCard({ timer }: TimerCardProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Countdown / Time */}
|
{/* Countdown / Time */}
|
||||||
{!isDone && (
|
{!isDone && timer.type === 'event' && (
|
||||||
|
<div className="mb-3">
|
||||||
|
{/* Large days display for event countdowns */}
|
||||||
|
{(() => {
|
||||||
|
const days = Math.floor(remaining / 86_400_000);
|
||||||
|
const hours = Math.floor((remaining % 86_400_000) / 3_600_000);
|
||||||
|
const totalDays = timer.duration ? Math.ceil(timer.duration / 86_400_000) : days;
|
||||||
|
const progress = totalDays > 0 ? Math.max(0, Math.min(1, 1 - days / totalDays)) : 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span
|
||||||
|
className="text-4xl font-mono font-bold tabular-nums tracking-tight"
|
||||||
|
style={{ color: days <= 1 ? urgencyConfig.color : 'var(--cm-accent)' }}
|
||||||
|
>
|
||||||
|
{days}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
|
day{days !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{hours}h remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Milestone progress bar */}
|
||||||
|
<div className="mt-2 h-2 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${progress * 100}%`,
|
||||||
|
backgroundColor: progress > 0.9 ? 'var(--cm-danger)' : progress > 0.7 ? 'var(--cm-warning)' : 'var(--cm-accent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>Created</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{new Date(timer.targetTime).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isDone && timer.type !== 'event' && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user