diff --git a/docs/images/pr-716/cumulative-dark.png b/docs/images/pr-716/cumulative-dark.png new file mode 100644 index 00000000..50c14b89 Binary files /dev/null and b/docs/images/pr-716/cumulative-dark.png differ diff --git a/docs/images/pr-716/heatmap-dark.png b/docs/images/pr-716/heatmap-dark.png new file mode 100644 index 00000000..04c1ceb8 Binary files /dev/null and b/docs/images/pr-716/heatmap-dark.png differ diff --git a/docs/images/pr-716/heatmap-light.png b/docs/images/pr-716/heatmap-light.png new file mode 100644 index 00000000..6c22de23 Binary files /dev/null and b/docs/images/pr-716/heatmap-light.png differ diff --git a/docs/images/pr-716/hover-dark.png b/docs/images/pr-716/hover-dark.png new file mode 100644 index 00000000..424a277b Binary files /dev/null and b/docs/images/pr-716/hover-dark.png differ diff --git a/docs/images/pr-716/weekly-dark.png b/docs/images/pr-716/weekly-dark.png new file mode 100644 index 00000000..ae19a181 Binary files /dev/null and b/docs/images/pr-716/weekly-dark.png differ diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index e0117fde..58dc3e64 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -22,6 +22,8 @@ export const ICONS: Record = { trash: 'M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6', // Trash refresh: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5', // Refresh ccw sparkle: 'M12 3L14.5 9.5 21 12 14.5 14.5 12 21 9.5 14.5 3 12 9.5 9.5 12 3Z', // Sparkle + sun: 'M12 4V2M12 22v-2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M4 12H2M22 12h-2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z', // Sun + moon: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z', // Moon bolt: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', // Zap clock: 'M12 8v4l3 3M12 22a10 10 0 1 1 0-20 10 10 0 0 1 0 20z', // Clock standard hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', // Hash diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fcf71ca3..f64d10c5 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -277,9 +277,26 @@ export const en: typeof zhCN = { metricNoData: 'No data', historyLoadError: 'History load failed', metricTotal: 'Total records', - metricTotalTrend: 'Local archive (max 200)', + metricTotalTrend: 'Local archive', weekTitle: 'Last 7 days', weekUnit: 'count / day', + activityTitle: 'Annual activity', + activityModeDaily: 'Daily', + activityMode: { + daily: 'Daily', + weekly: 'Weekly', + cumulative: 'Total', + }, + activityUnit: 'Past year · {{days}} active days', + activityEmpty: 'No history in the past year.', + activityTooltip: '{{date}} · {{count}} records · {{chars}} chars', + activitySummaryDaily: '{{date}} used {{count}} records / {{chars}} chars', + activitySummaryWeekly: 'Week of {{start}} used {{count}} records / {{chars}} chars', + activitySummaryCumulative: 'Through week of {{start}} used {{count}} records / {{chars}} chars', + activityLegendLow: 'Less', + activityLegendHigh: 'More', + themeToggle: 'Switch to {{mode}}', + monthNames: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], recentTitle: 'Recent transcripts', recentAll: 'View all →', recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 257bfb7f..17dd80a0 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -279,9 +279,26 @@ export const ja: typeof zhCN = { metricNoData: 'データなし', historyLoadError: '履歴の読み込みに失敗', metricTotal: '累計記録', - metricTotalTrend: 'ローカル保存(上限 200)', + metricTotalTrend: 'ローカル保存', weekTitle: '直近 7 日', weekUnit: '件 / 日', + activityTitle: '年間アクティビティ', + activityModeDaily: '毎日', + activityMode: { + daily: '毎日', + weekly: '毎週', + cumulative: '累計', + }, + activityUnit: '過去 1 年 · {{days}} 日に記録', + activityEmpty: '過去 1 年の履歴はまだありません。', + activityTooltip: '{{date}} · {{count}} 件 · {{chars}} 文字', + activitySummaryDaily: '{{date}} に {{count}} 件 / {{chars}} 文字', + activitySummaryWeekly: '{{start}} の週に {{count}} 件 / {{chars}} 文字', + activitySummaryCumulative: '{{start}} の週まで累計 {{count}} 件 / {{chars}} 文字', + activityLegendLow: '少', + activityLegendHigh: '多', + themeToggle: '{{mode}}に切り替え', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近の認識', recentAll: 'すべて表示 →', recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 395f92e1..2c7013a4 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -279,9 +279,26 @@ export const ko: typeof zhCN = { metricNoData: '데이터 없음', historyLoadError: '기록 로드 실패', metricTotal: '누적 기록', - metricTotalTrend: '로컬 보관(상한 200)', + metricTotalTrend: '로컬 보관', weekTitle: '최근 7일', weekUnit: '건/일', + activityTitle: '연간 활동', + activityModeDaily: '매일', + activityMode: { + daily: '매일', + weekly: '매주', + cumulative: '누적', + }, + activityUnit: '지난 1년 · {{days}}일 기록 있음', + activityEmpty: '지난 1년 동안 기록이 없습니다.', + activityTooltip: '{{date}} · {{count}}건 · {{chars}}자', + activitySummaryDaily: '{{date}} 사용 {{count}}건 / {{chars}}자', + activitySummaryWeekly: '{{start}} 주간 사용 {{count}}건 / {{chars}}자', + activitySummaryCumulative: '{{start}} 주까지 누적 사용 {{count}}건 / {{chars}}자', + activityLegendLow: '적음', + activityLegendHigh: '많음', + themeToggle: '{{mode}}로 전환', + monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'], recentTitle: '최근 인식', recentAll: '전체 보기 →', recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 3147b416..492a2a29 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -275,9 +275,26 @@ export const zhCN = { metricNoData: '暂无数据', historyLoadError: '历史读取失败', metricTotal: '累计记录', - metricTotalTrend: '本机存档 (上限 200)', + metricTotalTrend: '本机存档', weekTitle: '近 7 天', weekUnit: '条数 / 天', + activityTitle: '年度活动', + activityModeDaily: '每日', + activityMode: { + daily: '每日', + weekly: '每周', + cumulative: '累计', + }, + activityUnit: '过去一年 · {{days}} 天有记录', + activityEmpty: '过去一年还没有历史记录。', + activityTooltip: '{{date}} · {{count}} 条 · {{chars}} 字', + activitySummaryDaily: '{{date}} 使用了 {{count}} 条 / {{chars}} 字', + activitySummaryWeekly: '{{start}} 当周使用了 {{count}} 条 / {{chars}} 字', + activitySummaryCumulative: '截至 {{start}} 当周累计使用了 {{count}} 条 / {{chars}} 字', + activityLegendLow: '少', + activityLegendHigh: '多', + themeToggle: '切换到 {{mode}}', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近识别', recentAll: '全部记录 →', recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2fe58825..5e20cbfd 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -277,9 +277,26 @@ export const zhTW: typeof zhCN = { metricNoData: '暫無數據', historyLoadError: '歷史讀取失敗', metricTotal: '累計記錄', - metricTotalTrend: '本機存檔 (上限 200)', + metricTotalTrend: '本機存檔', weekTitle: '近 7 天', weekUnit: '條數 / 天', + activityTitle: '年度活動', + activityModeDaily: '每日', + activityMode: { + daily: '每日', + weekly: '每週', + cumulative: '累計', + }, + activityUnit: '過去一年 · {{days}} 天有記錄', + activityEmpty: '過去一年還沒有歷史記錄。', + activityTooltip: '{{date}} · {{count}} 條 · {{chars}} 字', + activitySummaryDaily: '{{date}} 使用了 {{count}} 條 / {{chars}} 字', + activitySummaryWeekly: '{{start}} 當週使用了 {{count}} 條 / {{chars}} 字', + activitySummaryCumulative: '截至 {{start}} 當週累計使用了 {{count}} 條 / {{chars}} 字', + activityLegendLow: '少', + activityLegendHigh: '多', + themeToggle: '切換到 {{mode}}', + monthNames: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], recentTitle: '最近識別', recentAll: '全部記錄 →', recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..e19771b0 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -431,23 +431,40 @@ export const mockMicrophoneDevices: MicrophoneDevice[] = [ { name: "USB Microphone", isDefault: false }, ] -export const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ - id: `mock-${i}`, - createdAt: new Date().toISOString(), - rawTranscript: h.preview, - finalText: h.preview, - mode: "structured", - stylePackId: "builtin.structured", - translationActive: false, - polishSource: null, - appBundleId: null, - appName: "VS Code", - insertStatus: "inserted", - errorCode: null, - durationMs: 600, - dictionaryEntryCount: 28, - hasAudioRecording: null, -})) +const mockHistoryOffsets = [0, 0, 0, 1, 1, 2, 3, 3, 4, 5, 6, 15, 32, 54, 91, 130, 178, 230, 292, 340] +const mockHistoryModes: PolishMode[] = ["structured", "light", "raw", "formal"] +const mockHistoryApps = ["VS Code", "Obsidian", "Chrome", "微信", "Word"] + +function mockCreatedAt(dayOffset: number, index: number): string { + const source = OL_DATA.history[index % OL_DATA.history.length] + const [hours = 12, minutes = 0] = source.time.split(":").map(Number) + const date = new Date() + date.setDate(date.getDate() - dayOffset) + date.setHours(hours, minutes, 0, 0) + return date.toISOString() +} + +export const mockHistory: DictationSession[] = mockHistoryOffsets.map((dayOffset, i) => { + const h = OL_DATA.history[i % OL_DATA.history.length] + const mode = mockHistoryModes[i % mockHistoryModes.length] + return { + id: `mock-${dayOffset}-${i}`, + createdAt: mockCreatedAt(dayOffset, i), + rawTranscript: h.preview, + finalText: h.preview, + mode, + stylePackId: `builtin.${mode}`, + translationActive: false, + polishSource: null, + appBundleId: null, + appName: mockHistoryApps[i % mockHistoryApps.length], + insertStatus: "inserted", + errorCode: null, + durationMs: 9000 + (i % 6) * 1800, + dictionaryEntryCount: 12 + (i % 5) * 4, + hasAudioRecording: null, + } +}) export const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({ id: `vocab-${i}`, diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 6e9d77ff..d85a3bea 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -1,10 +1,11 @@ // Overview.tsx — 真实指标,从 listHistory + getCredentials 派生。 -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { formatComboLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; +import { resolveTheme, setThemePreference, type ResolvedTheme } from '../lib/themeMode'; import { useMobileLayout } from '../lib/useMobileLayout'; import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -52,6 +53,10 @@ const LLM_NAME_KEY_BY_ID: Record = { custom: 'custom', }; +type ActivityMode = 'daily' | 'weekly' | 'cumulative'; + +const ACTIVITY_MODES: ActivityMode[] = ['daily', 'weekly', 'cumulative']; + export function Overview({ onOpenHistory }: OverviewProps) { const { t } = useTranslation(); const mobile = useMobileLayout(); @@ -67,7 +72,8 @@ export function Overview({ onOpenHistory }: OverviewProps) { volcengineConfigured: false, arkConfigured: false, }); - const { prefs } = useHotkeySettings(); + const [activityMode, setActivityMode] = useState('daily'); + const { prefs, updatePrefs } = useHotkeySettings(); const credentialsRequestSeq = useRef(0); const refreshHistory = useCallback(() => { @@ -159,6 +165,26 @@ export function Overview({ onOpenHistory }: OverviewProps) { return buckets; }, [history]); + const monthNames = useMemo( + () => t('overview.monthNames', { returnObjects: true }) as string[], + [t], + ); + + const yearlyActivity = useMemo( + () => buildYearlyActivity(history, monthNames, activityMode), + [history, monthNames, activityMode], + ); + const resolvedTheme = resolveTheme(prefs?.themeMode ?? 'system'); + const nextTheme = resolvedTheme === 'dark' ? 'light' : 'dark'; + const toggleTheme = useCallback(() => { + setThemePreference(nextTheme); + void updatePrefs(current => ( + current.themeMode === nextTheme ? current : { ...current, themeMode: nextTheme } + )).catch(error => { + console.error('[overview] failed to update theme mode', error); + }); + }, [nextTheme, updatePrefs]); + const asrProviderId = creds.activeAsrProvider || 'volcengine'; const llmProviderId = creds.activeLlmProvider || 'ark'; const asrNameKey = ASR_NAME_KEY_BY_ID[asrProviderId]; @@ -172,7 +198,15 @@ export function Overview({ onOpenHistory }: OverviewProps) { return ( <> - + + } + />
-
+
0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} />
- {/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。 - 只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。 - issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */} -
- + {/* 近 7 天和最近识别固定一个可读高度;最近识别内部滚动,避免和年度活动互相遮挡。 */} +
+
{t('overview.weekTitle')} {t('overview.weekUnit')} @@ -212,12 +244,15 @@ export function Overview({ onOpenHistory }: OverviewProps) { ) : ( )} -
- {weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => {d})} +
+
- +
{t('overview.recentTitle')} {t('overview.recentAll')} @@ -243,10 +278,61 @@ export function Overview({ onOpenHistory }: OverviewProps) {
+ + ); } +function ThemeToggleButton({ + resolvedTheme, + onToggleTheme, +}: { + resolvedTheme: ResolvedTheme; + onToggleTheme: () => void; +}) { + const { t } = useTranslation(); + const nextTheme = resolvedTheme === 'dark' ? 'light' : 'dark'; + const nextThemeLabel = t(`settings.theme.${nextTheme}`); + + return ( + + ); +} + interface ProviderCardProps { kind: string; name: string; @@ -318,27 +404,423 @@ function Metric({ icon, label, value, trend, accent }: MetricProps) { function WeekChart({ data }: { data: number[] }) { const max = Math.max(...data, 1); + const mid = Math.ceil(max / 2); + return ( +
+
+ {max} + {mid} + 0 +
+
+
+ ); +} + +interface DayActivity { + key: string; + date: Date; + rawCount: number; + rawChars: number; + count: number; + chars: number; + level: number; + inRange: boolean; +} + +interface WeekActivity { + key: string; + startDate: Date; + endDate: Date; + count: number; + chars: number; + cumulativeCount: number; + cumulativeChars: number; + value: number; + valueChars: number; + level: number; + inRange: boolean; +} + +interface MonthLabel { + weekIndex: number; + label: string; +} + +interface YearlyActivity { + cells: DayActivity[]; + weeks: number; + weekColumns: number; + activeDays: number; + maxCount: number; + maxWeekValue: number; + weekBars: WeekActivity[]; + monthLabels: MonthLabel[]; + weekMonthLabels: MonthLabel[]; +} + +function ActivityHeatmapCard({ + activity, + mode, + onModeChange, + historyError, +}: { + activity: YearlyActivity; + mode: ActivityMode; + onModeChange: (mode: ActivityMode) => void; + historyError: boolean; +}) { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const cardRef = useRef(null); + const [hoveredKey, setHoveredKey] = useState(null); + const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number; align: 'left' | 'center' | 'right' } | null>(null); + const cellSize = 12; + const cellGap = 4; + const dayGridWidth = activity.weeks * cellSize + Math.max(0, activity.weeks - 1) * cellGap; + const weekGridWidth = activity.weekColumns * cellSize + Math.max(0, activity.weekColumns - 1) * cellGap; + const hoveredDay = mode === 'daily' && hoveredKey + ? activity.cells.find(cell => cell.key === hoveredKey) ?? null + : null; + const hoveredWeek = mode !== 'daily' && hoveredKey + ? activity.weekBars.find(week => week.key === hoveredKey) ?? null + : null; + const summaryArgs = hoveredDay + ? activityDaySummaryArgs(hoveredDay) + : hoveredWeek && mode !== 'daily' + ? activityWeekSummaryArgs(mode, hoveredWeek) + : null; + const summaryText = summaryArgs ? t(summaryArgs.key, summaryArgs.options) : null; + const showTooltip = Boolean(summaryText && hoverPoint); + const handleHover = useCallback((key: string, event: PointerEvent) => { + const cardRect = cardRef.current?.getBoundingClientRect(); + const targetRect = event.currentTarget.getBoundingClientRect(); + if (!cardRect) return; + const x = targetRect.left + targetRect.width / 2 - cardRect.left; + const y = targetRect.top - cardRect.top; + const align = x < 140 ? 'left' : x > cardRect.width - 140 ? 'right' : 'center'; + setHoveredKey(key); + setHoverPoint({ x, y, align }); + }, []); + const clearHover = useCallback(() => { + setHoveredKey(null); + setHoverPoint(null); + }, []); + + return ( + +
+
+
+ {t('overview.activityTitle')} +
+
+ {ACTIVITY_MODES.map(activityMode => { + const selected = activityMode === mode; + return ( + + ); + })} +
+
+ + {historyError ? ( +
{t('overview.historyLoadError')}
+ ) : activity.activeDays === 0 ? ( +
{t('overview.activityEmpty')}
+ ) : mode === 'daily' ? ( + + ) : ( + + )} + {showTooltip && hoverPoint && ( +
+ {summaryText} +
+ )} + + ); +} + +function ActivityDailyGrid({ + mode, + activity, + cellSize, + cellGap, + minGridWidth, + hoveredKey, + onHover, + onLeave, +}: { + mode: ActivityMode; + activity: YearlyActivity; + cellSize: number; + cellGap: number; + minGridWidth: number; + hoveredKey: string | null; + onHover: (key: string, event: PointerEvent) => void; + onLeave: () => void; +}) { + const { t } = useTranslation(); return ( -
- {data.map((v, i) => { - const isToday = i === 6; - return ( -
-
{v}
-
+
+
+
+
+ {activity.cells.map(cell => { + const selected = hoveredKey === cell.key; + const cellSummary = activityDaySummaryArgs(cell); + const weekIndex = Math.floor(differenceInDays(cell.date, activity.cells[0]?.date ?? cell.date) / 7); + return ( +
{ + if (cell.inRange) onHover(cell.key, event); + }} + onPointerMove={(event) => { + if (cell.inRange) onHover(cell.key, event); + }} + style={{ + width: cellSize, + height: cellSize, + borderRadius: 4, + background: activityColor(cell.level), + opacity: cell.inRange ? 1 : 0, + boxShadow: cell.inRange + ? selected + ? '0 0 0 1.5px color-mix(in srgb, var(--ol-ink) 42%, transparent) inset' + : '0 0 0 0.5px color-mix(in srgb, var(--ol-ink) 10%, transparent) inset' + : 'none', + cursor: cell.inRange ? 'default' : 'auto', + transform: 'scale(1)', + animation: cell.inRange ? `ol-activity-cell-in 0.22s var(--ol-motion-soft) ${Math.min(Math.max(0, weekIndex) * 5, 260)}ms both` : undefined, + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)', + }} + /> + ); + })}
- ); - })} + +
+
+
+ ); +} + +function ActivityWeekGrid({ + activity, + mode, + cellSize, + cellGap, + minGridWidth, + hoveredKey, + onHover, + onLeave, +}: { + activity: YearlyActivity; + mode: Exclude; + cellSize: number; + cellGap: number; + minGridWidth: number; + hoveredKey: string | null; + onHover: (key: string, event: PointerEvent) => void; + onLeave: () => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+
+
+ {activity.weekBars.flatMap((week, weekIndex) => { + const summary = activityWeekSummaryArgs(mode, week); + const filledCells = activityDiscreteCells(week.value, activity.maxWeekValue); + return Array.from({ length: 7 }).map((_, rowIndex) => { + const selected = hoveredKey === week.key; + const filled = rowIndex >= 7 - filledCells; + return ( +
{ + if (week.inRange) onHover(week.key, event); + }} + onPointerMove={(event) => { + if (week.inRange) onHover(week.key, event); + }} + style={{ + width: cellSize, + height: cellSize, + borderRadius: 4, + background: filled ? activityColor(week.level) : activityColor(0), + opacity: week.inRange ? 1 : 0, + boxShadow: week.inRange + ? selected + ? '0 0 0 1.5px color-mix(in srgb, var(--ol-ink) 42%, transparent) inset' + : '0 0 0 0.5px color-mix(in srgb, var(--ol-ink) 10%, transparent) inset' + : 'none', + cursor: week.inRange ? 'default' : 'auto', + transform: 'scale(1)', + animation: week.inRange ? `ol-activity-cell-in 0.22s var(--ol-motion-soft) ${Math.min(weekIndex * 5, 260)}ms both` : undefined, + transition: 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick), box-shadow 0.12s var(--ol-motion-quick)', + }} + /> + ); + }); + })} +
+ +
+
+
+ ); +} + +function ActivityMonthLabels({ + labels, + weeks, + cellSize, + cellGap, +}: { + labels: MonthLabel[]; + weeks: number; + cellSize: number; + cellGap: number; +}) { + return ( +
+ {labels.map(label => ( + + {label.label} + + ))}
); } @@ -363,6 +845,223 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe ); } +function buildYearlyActivity(history: DictationSession[], monthNames: string[], mode: ActivityMode): YearlyActivity { + const today = startOfLocalDay(new Date()); + const rangeStart = addDays(today, -364); + const gridStart = rangeStart; + const weeks = Math.ceil((differenceInDays(today, gridStart) + 1) / 7); + const weekGridStart = startOfWeek(rangeStart); + const weekColumns = Math.ceil((differenceInDays(today, weekGridStart) + 1) / 7); + const byDay = new Map(); + + history.forEach(session => { + const date = startOfLocalDay(new Date(session.createdAt)); + if (isNaN(date.getTime()) || date < rangeStart || date > today) return; + const key = localDateKey(date); + const current = byDay.get(key) ?? { count: 0, chars: 0 }; + current.count += 1; + current.chars += session.finalText.length; + byDay.set(key, current); + }); + + const rawCells: Array> = []; + for (let i = 0; i < weeks * 7; i += 1) { + const date = addDays(gridStart, i); + const inRange = date >= rangeStart && date <= today; + const key = localDateKey(date); + const stats = inRange ? byDay.get(key) : undefined; + rawCells.push({ + key, + date, + rawCount: stats?.count ?? 0, + rawChars: stats?.chars ?? 0, + inRange, + }); + } + + let cumulativeCount = 0; + let cumulativeChars = 0; + const metricCells = rawCells.map(cell => ({ + ...cell, + count: cell.inRange ? cell.rawCount : 0, + chars: cell.inRange ? cell.rawChars : 0, + })); + + const maxCount = Math.max(...metricCells.filter(cell => cell.inRange).map(cell => cell.count), 0); + const cells = metricCells.map(cell => ({ + ...cell, + level: cell.inRange ? activityLevel(cell.count, maxCount) : 0, + })); + + const rawWeeks: Array> = []; + for (let weekIndex = 0; weekIndex < weekColumns; weekIndex += 1) { + const startDate = addDays(weekGridStart, weekIndex * 7); + const endDate = addDays(startDate, 6); + const inRange = endDate >= rangeStart && startDate <= today; + let stats = { count: 0, chars: 0 }; + for (let offset = 0; offset < 7; offset += 1) { + const date = addDays(startDate, offset); + if (date < rangeStart || date > today) continue; + const dayStats = byDay.get(localDateKey(date)); + if (!dayStats) continue; + stats = { count: stats.count + dayStats.count, chars: stats.chars + dayStats.chars }; + } + if (inRange) { + cumulativeCount += stats.count; + cumulativeChars += stats.chars; + } + const value = mode === 'cumulative' ? cumulativeCount : stats.count; + const valueChars = mode === 'cumulative' ? cumulativeChars : stats.chars; + rawWeeks.push({ + key: localDateKey(startDate), + startDate, + endDate: endDate > today ? today : endDate, + count: inRange ? stats.count : 0, + chars: inRange ? stats.chars : 0, + cumulativeCount: inRange ? cumulativeCount : 0, + cumulativeChars: inRange ? cumulativeChars : 0, + value: inRange ? value : 0, + valueChars: inRange ? valueChars : 0, + inRange, + }); + } + + const maxWeekValue = Math.max(...rawWeeks.filter(week => week.inRange).map(week => week.value), 0); + const weekBars = rawWeeks.map(week => ({ + ...week, + level: week.inRange ? activityLevel(week.value, maxWeekValue) : 0, + })); + + const monthLabels: MonthLabel[] = []; + let lastMonth = -1; + for (let weekIndex = 0; weekIndex < weeks; weekIndex += 1) { + const weekCells = cells.slice(weekIndex * 7, weekIndex * 7 + 7).filter(cell => cell.inRange); + const candidate = weekCells.find(cell => cell.date.getDate() === 1); + if (!candidate) continue; + const month = candidate.date.getMonth(); + if (month === lastMonth) continue; + lastMonth = month; + monthLabels.push({ + weekIndex, + label: monthNames[month] ?? String(month + 1), + }); + } + const weekMonthLabels: MonthLabel[] = []; + let lastWeekMonth = -1; + for (let weekIndex = 0; weekIndex < weekColumns; weekIndex += 1) { + let candidate: Date | null = null; + const weekStart = addDays(weekGridStart, weekIndex * 7); + for (let offset = 0; offset < 7; offset += 1) { + const date = addDays(weekStart, offset); + if (date >= rangeStart && date <= today && date.getDate() === 1) { + candidate = date; + break; + } + } + if (!candidate) continue; + const month = candidate.getMonth(); + if (month === lastWeekMonth) continue; + lastWeekMonth = month; + weekMonthLabels.push({ + weekIndex, + label: monthNames[month] ?? String(month + 1), + }); + } + + return { + cells, + weeks, + weekColumns, + activeDays: rawCells.filter(cell => cell.inRange && cell.rawCount > 0).length, + maxCount, + maxWeekValue, + weekBars, + monthLabels, + weekMonthLabels, + }; +} + +function activityLevel(count: number, maxCount: number): number { + if (count <= 0 || maxCount <= 0) return 0; + const ratio = count / maxCount; + if (ratio <= 0.25) return 1; + if (ratio <= 0.5) return 2; + if (ratio <= 0.75) return 3; + return 4; +} + +function activityDiscreteCells(value: number, maxValue: number): number { + if (value <= 0 || maxValue <= 0) return 0; + return Math.min(7, Math.max(1, Math.ceil((value / maxValue) * 7))); +} + +function activityColor(level: number): string { + const base = 'var(--ol-blue)'; + switch (level) { + case 1: + return `color-mix(in srgb, ${base} 26%, var(--ol-surface))`; + case 2: + return `color-mix(in srgb, ${base} 44%, var(--ol-surface))`; + case 3: + return `color-mix(in srgb, ${base} 68%, var(--ol-surface))`; + case 4: + return base; + default: + return 'color-mix(in srgb, var(--ol-ink) 8%, var(--ol-surface))'; + } +} + +function activityDaySummaryArgs(cell: DayActivity): { key: string; options: Record } { + const date = formatHeatmapMonthDay(cell.date); + return { + key: 'overview.activitySummaryDaily', + options: { date, count: cell.rawCount.toLocaleString(), chars: cell.rawChars.toLocaleString() }, + }; +} + +function activityWeekSummaryArgs(mode: Exclude, week: WeekActivity): { key: string; options: Record } { + const start = formatHeatmapDisplayDate(week.startDate); + if (mode === 'weekly') { + return { + key: 'overview.activitySummaryWeekly', + options: { start, count: week.count.toLocaleString(), chars: week.chars.toLocaleString() }, + }; + } + return { + key: 'overview.activitySummaryCumulative', + options: { start, count: week.cumulativeCount.toLocaleString(), chars: week.cumulativeChars.toLocaleString() }, + }; +} + +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function startOfWeek(date: Date): Date { + return addDays(startOfLocalDay(date), -((date.getDay() + 6) % 7)); +} + +function addDays(date: Date, days: number): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); +} + +function differenceInDays(a: Date, b: Date): number { + return Math.round((startOfLocalDay(a).getTime() - startOfLocalDay(b).getTime()) / 86400000); +} + +function localDateKey(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function formatHeatmapDisplayDate(date: Date): string { + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); +} + +function formatHeatmapMonthDay(date: Date): string { + return date.toLocaleDateString(undefined, { month: 'long', day: 'numeric' }); +} + function formatTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return iso; diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index dc08e742..319f0814 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -122,6 +122,11 @@ a { color: inherit; text-decoration: none; } to { transform: rotate(360deg); } } +@keyframes ol-activity-cell-in { + from { opacity: 0; } + to { opacity: 1; } +} + @keyframes ol-modal-drawer-in { from { opacity: 0;