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:
saravanakumardb1 2026-02-27 22:31:20 -08:00
parent f61483e7a5
commit d909830fcd
3 changed files with 88 additions and 3 deletions

View File

@ -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] Label normalization for pattern grouping (e.g. "Meeting with Bob" → "meeting with")
- [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
- [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] Event tab in CreateTimerModal with date picker + days-remaining preview
- [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] "Export timers" → JSON download of all timers

View File

@ -17,12 +17,16 @@ import Link from 'next/link';
import { FeedbackButton } from './FeedbackButton';
import { InstallPrompt } from './InstallPrompt';
import { useTheme } from '@/lib/use-theme';
import { getSnoozeSuggestions } from '@/lib/adaptive-snooze';
import type { SnoozeSuggestion } from '@/lib/adaptive-snooze';
export function Dashboard() {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [mounted, setMounted] = useState(false);
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 now = useTimerStore((s) => s.now);
const { pause, resume } = useTimerStore();
@ -56,6 +60,7 @@ export function Dashboard() {
useEffect(() => {
setMounted(true);
requestNotificationPermission();
setSnoozeSuggestions(getSnoozeSuggestions());
}, []);
if (!mounted) {
@ -267,6 +272,41 @@ export function Dashboard() {
))}
</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 */}
{activeTimers.length > 0 ? (
<div className="space-y-3">

View File

@ -130,7 +130,52 @@ export function TimerCard({ timer }: TimerCardProps) {
</h3>
{/* 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="flex items-baseline gap-2">
<span