super-time-tracker-ui 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +28 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +36 -0
  5. package/app/api/backup/route.ts +39 -0
  6. package/app/api/entry/delete-bulk/route.ts +53 -0
  7. package/app/api/entry/move/route.ts +46 -0
  8. package/app/api/entry/move-bulk/route.ts +62 -0
  9. package/app/api/entry/route.ts +75 -0
  10. package/app/api/in/route.ts +38 -0
  11. package/app/api/note/route.ts +120 -0
  12. package/app/api/out/route.ts +31 -0
  13. package/app/api/sheet/route.ts +68 -0
  14. package/app/api/state/route.ts +16 -0
  15. package/app/api/tags/route.ts +75 -0
  16. package/app/color-palettes.css +260 -0
  17. package/app/favicon.ico +0 -0
  18. package/app/globals.css +140 -0
  19. package/app/layout.tsx +54 -0
  20. package/app/page.tsx +24 -0
  21. package/app/reporting/page.tsx +11 -0
  22. package/app/settings/data/page.tsx +9 -0
  23. package/app/settings/display/page.tsx +8 -0
  24. package/app/settings/page.tsx +12 -0
  25. package/app/settings/tags/page.tsx +13 -0
  26. package/bin/stt-ui.js +63 -0
  27. package/components/active-entry-panel.tsx +199 -0
  28. package/components/backup-restore-setting.tsx +168 -0
  29. package/components/check-in-form-collapsed-setting.tsx +44 -0
  30. package/components/check-in-form-collapsible.tsx +52 -0
  31. package/components/check-in-form.tsx +89 -0
  32. package/components/checkbox.tsx +75 -0
  33. package/components/checkout-button-group.tsx +73 -0
  34. package/components/chevron-icon.tsx +25 -0
  35. package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
  36. package/components/color-palette-setting.tsx +75 -0
  37. package/components/compact-lists-setting.tsx +42 -0
  38. package/components/confirm-before-checkout-setting.tsx +42 -0
  39. package/components/confirm-destructive-actions-setting.tsx +46 -0
  40. package/components/confirm-dialog-provider.tsx +71 -0
  41. package/components/confirm-dialog.tsx +90 -0
  42. package/components/data-settings-view.tsx +47 -0
  43. package/components/default-reporting-range-setting.tsx +56 -0
  44. package/components/default-reporting-sort-setting.tsx +45 -0
  45. package/components/default-sheet-session-setting.tsx +118 -0
  46. package/components/display-settings-view.tsx +75 -0
  47. package/components/duration-format-setting.tsx +40 -0
  48. package/components/entry-actions-menu.tsx +207 -0
  49. package/components/entry-edit-form.tsx +113 -0
  50. package/components/entry-list-bulk-bar.tsx +128 -0
  51. package/components/entry-list-sort-setting.tsx +41 -0
  52. package/components/entry-list.tsx +336 -0
  53. package/components/entry-notes-list.tsx +211 -0
  54. package/components/entry-tag-filter.tsx +99 -0
  55. package/components/format_datetime_hint.ts +8 -0
  56. package/components/format_time.ts +10 -0
  57. package/components/general-settings-view.tsx +40 -0
  58. package/components/hamburger-icon.tsx +21 -0
  59. package/components/note-edit-form.tsx +77 -0
  60. package/components/note-form.tsx +109 -0
  61. package/components/pencil-icon.tsx +21 -0
  62. package/components/reporting-date-range-picker.tsx +121 -0
  63. package/components/reporting-sort-controls.tsx +53 -0
  64. package/components/reporting-view.tsx +340 -0
  65. package/components/setting-radio-group.tsx +79 -0
  66. package/components/settings-nav.tsx +66 -0
  67. package/components/settings-page-layout.tsx +53 -0
  68. package/components/settings-saved-toast.tsx +57 -0
  69. package/components/sheet-actions-menu.tsx +108 -0
  70. package/components/sheet-sidebar.tsx +196 -0
  71. package/components/tag-autocomplete-input.tsx +183 -0
  72. package/components/tag-filter-mode-setting.tsx +47 -0
  73. package/components/tag-management-view.tsx +290 -0
  74. package/components/theme-mode-setting.tsx +44 -0
  75. package/components/theme-mode-system-listener.tsx +43 -0
  76. package/components/theme_switcher.tsx +38 -0
  77. package/components/time-format-setting.tsx +39 -0
  78. package/components/timer-in-title-setting.tsx +38 -0
  79. package/components/timer-show-seconds-setting.tsx +41 -0
  80. package/components/tracker-active-bar.tsx +76 -0
  81. package/components/tracker-app.tsx +338 -0
  82. package/components/tracker-breadcrumb.tsx +56 -0
  83. package/components/tracker-document-title.tsx +67 -0
  84. package/components/tracker-topbar.tsx +63 -0
  85. package/components/trash-icon.tsx +24 -0
  86. package/components/week-starts-on-setting.tsx +39 -0
  87. package/eslint.config.mjs +18 -0
  88. package/lib/add_note_to_entry.ts +65 -0
  89. package/lib/api_error_response.ts +10 -0
  90. package/lib/apply_accent_color.ts +12 -0
  91. package/lib/apply_color_palette.ts +12 -0
  92. package/lib/apply_compact_lists.ts +9 -0
  93. package/lib/apply_tag_autocomplete_selection.ts +26 -0
  94. package/lib/apply_theme.ts +8 -0
  95. package/lib/build_reporting_stats.ts +55 -0
  96. package/lib/build_resume_description.ts +15 -0
  97. package/lib/check_in_entry.ts +81 -0
  98. package/lib/check_out_entry.ts +75 -0
  99. package/lib/collect_known_tags.ts +22 -0
  100. package/lib/collect_tag_stats.ts +27 -0
  101. package/lib/collect_tags_from_entries.ts +35 -0
  102. package/lib/config.ts +9 -0
  103. package/lib/convert_json_db.ts +49 -0
  104. package/lib/delete_entries.ts +62 -0
  105. package/lib/delete_entry.ts +29 -0
  106. package/lib/delete_note_on_entry.ts +42 -0
  107. package/lib/delete_sheet.ts +30 -0
  108. package/lib/delete_tracker_action.ts +22 -0
  109. package/lib/edit_entry.ts +56 -0
  110. package/lib/edit_note_on_entry.ts +49 -0
  111. package/lib/ensure_dir_exists.ts +22 -0
  112. package/lib/entry_matches_tag_filter.ts +26 -0
  113. package/lib/fetch_tracker_state.ts +15 -0
  114. package/lib/filter_entries_by_tags.ts +20 -0
  115. package/lib/filter_known_tags.ts +20 -0
  116. package/lib/find_all_serialized_active_entries.ts +28 -0
  117. package/lib/find_serialized_active_entry.ts +12 -0
  118. package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
  119. package/lib/find_sheet_with_active_entry.ts +16 -0
  120. package/lib/format_display_tag.ts +6 -0
  121. package/lib/format_duration.ts +45 -0
  122. package/lib/gen_db.ts +43 -0
  123. package/lib/get_active_panel_class_name.ts +20 -0
  124. package/lib/get_average_entry_ms.ts +13 -0
  125. package/lib/get_button_class_name.ts +24 -0
  126. package/lib/get_check_out_confirm_dialog.ts +19 -0
  127. package/lib/get_clipped_entry_duration_ms.ts +18 -0
  128. package/lib/get_compact_lists_snapshot.ts +15 -0
  129. package/lib/get_date_range_ms_from_inputs.ts +31 -0
  130. package/lib/get_delete_entries_confirm_dialog.ts +21 -0
  131. package/lib/get_delete_entry_confirm_dialog.ts +19 -0
  132. package/lib/get_delete_note_confirm_dialog.ts +21 -0
  133. package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
  134. package/lib/get_entry_duration_ms.ts +14 -0
  135. package/lib/get_entry_row_key.ts +8 -0
  136. package/lib/get_initial_preferred_sheet_name.ts +34 -0
  137. package/lib/get_initial_reporting_range_inputs.ts +31 -0
  138. package/lib/get_input_class_name.ts +15 -0
  139. package/lib/get_merge_tags_confirm_dialog.ts +25 -0
  140. package/lib/get_period_range_ms.ts +43 -0
  141. package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
  142. package/lib/get_reporting_period_totals.ts +39 -0
  143. package/lib/get_reporting_stats.ts +25 -0
  144. package/lib/get_restore_db_confirm_dialog.ts +14 -0
  145. package/lib/get_running_entry_key.ts +8 -0
  146. package/lib/get_serialized_entries_total_ms.ts +10 -0
  147. package/lib/get_sheet.ts +14 -0
  148. package/lib/get_sheet_report_stats.ts +22 -0
  149. package/lib/get_sheet_report_stats_for_range.ts +46 -0
  150. package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
  151. package/lib/get_sheets_duration_in_range.ts +27 -0
  152. package/lib/get_tag_autocomplete_context.ts +32 -0
  153. package/lib/get_theme_snapshot.ts +16 -0
  154. package/lib/get_tracker_state.ts +67 -0
  155. package/lib/has_string_value.ts +6 -0
  156. package/lib/is_entry_in_day.ts +15 -0
  157. package/lib/is_idle_sheet_report.ts +8 -0
  158. package/lib/is_json_time_tracker_db.ts +14 -0
  159. package/lib/merge_tags_across_db.ts +79 -0
  160. package/lib/migrate_json_db.ts +56 -0
  161. package/lib/migrate_json_db_to_version_three.ts +51 -0
  162. package/lib/migrate_json_db_to_version_two.ts +50 -0
  163. package/lib/move_entries_to_sheet.ts +152 -0
  164. package/lib/move_entry_to_sheet.ts +82 -0
  165. package/lib/normalize_stored_tag.ts +16 -0
  166. package/lib/notify_settings_saved.ts +47 -0
  167. package/lib/parse_default_sheet_session_mode.ts +21 -0
  168. package/lib/parse_entry_from_input.ts +23 -0
  169. package/lib/parse_natural_language_date.ts +23 -0
  170. package/lib/parse_reporting_source_sheets.ts +22 -0
  171. package/lib/partition_sheet_report_stats.ts +30 -0
  172. package/lib/patch_tracker_action.ts +22 -0
  173. package/lib/persist_ui_preference.ts +18 -0
  174. package/lib/post_tracker_action.ts +22 -0
  175. package/lib/preferences/accent_color_preference.ts +21 -0
  176. package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
  177. package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
  178. package/lib/preferences/color_palette_preference.ts +21 -0
  179. package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
  180. package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
  181. package/lib/preferences/default_reporting_range_preference.ts +21 -0
  182. package/lib/preferences/default_reporting_sort_preference.ts +24 -0
  183. package/lib/preferences/duration_format_preference.ts +19 -0
  184. package/lib/preferences/entry_list_sort_preference.ts +21 -0
  185. package/lib/preferences/tag_filter_mode_preference.ts +18 -0
  186. package/lib/preferences/theme_mode_preference.ts +18 -0
  187. package/lib/preferences/time_format_preference.ts +18 -0
  188. package/lib/preferences/timer_in_title_preference.ts +18 -0
  189. package/lib/preferences/timer_show_seconds_preference.ts +19 -0
  190. package/lib/preferences/week_starts_on_preference.ts +19 -0
  191. package/lib/prompt_check_out_at.ts +17 -0
  192. package/lib/prompt_entry_note.ts +14 -0
  193. package/lib/prune_sheet_tag_filter.ts +27 -0
  194. package/lib/read_db.ts +49 -0
  195. package/lib/read_db_backup_contents.ts +22 -0
  196. package/lib/read_document_compact_lists.ts +12 -0
  197. package/lib/read_document_theme.ts +14 -0
  198. package/lib/read_sheet_tag_filter.ts +26 -0
  199. package/lib/read_stored_active_sheet.ts +14 -0
  200. package/lib/read_stored_compact_lists.ts +24 -0
  201. package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
  202. package/lib/read_stored_default_sheet_session_mode.ts +18 -0
  203. package/lib/read_stored_sheet_tag_filters.ts +28 -0
  204. package/lib/read_stored_theme.ts +18 -0
  205. package/lib/rename_sheet.ts +39 -0
  206. package/lib/rename_tag_across_db.ts +19 -0
  207. package/lib/resolve_active_sheet_name.ts +36 -0
  208. package/lib/resolve_session_preferred_sheet.ts +37 -0
  209. package/lib/resolve_theme.ts +18 -0
  210. package/lib/resolve_theme_mode_to_theme.ts +19 -0
  211. package/lib/restore_db_from_uploaded_json.ts +24 -0
  212. package/lib/serialize_entry.ts +27 -0
  213. package/lib/serialize_reporting_source_sheets.ts +19 -0
  214. package/lib/serialize_sheet_entries.ts +18 -0
  215. package/lib/set_accent_color.ts +12 -0
  216. package/lib/set_active_sheet.ts +18 -0
  217. package/lib/set_color_palette.ts +12 -0
  218. package/lib/set_compact_lists.ts +12 -0
  219. package/lib/set_default_sheet_fixed_name.ts +8 -0
  220. package/lib/set_default_sheet_session_mode.ts +11 -0
  221. package/lib/set_sheet_tag_filter.ts +13 -0
  222. package/lib/set_theme_mode.ts +19 -0
  223. package/lib/sheet_tag_filter_snapshots.ts +48 -0
  224. package/lib/sort_serialized_entries.ts +35 -0
  225. package/lib/sort_sheet_report_stats.ts +43 -0
  226. package/lib/subscribe_compact_lists.ts +25 -0
  227. package/lib/subscribe_sheet_tag_filters.ts +28 -0
  228. package/lib/subscribe_theme.ts +23 -0
  229. package/lib/sync_active_sheet_preference.ts +19 -0
  230. package/lib/tags_are_equal.ts +12 -0
  231. package/lib/theme_init_script.ts +11 -0
  232. package/lib/toggle_sheet_tag_filter.ts +28 -0
  233. package/lib/toggle_theme.ts +20 -0
  234. package/lib/types/confirm_dialog.ts +9 -0
  235. package/lib/types/data.ts +16 -0
  236. package/lib/types/generic_data.ts +25 -0
  237. package/lib/types/index.ts +2 -0
  238. package/lib/types/reporting.ts +59 -0
  239. package/lib/types/tag_management.ts +7 -0
  240. package/lib/types/theme.ts +3 -0
  241. package/lib/types/tracker_state.ts +39 -0
  242. package/lib/types/ui_preferences.ts +104 -0
  243. package/lib/types/ui_settings.ts +17 -0
  244. package/lib/ui_preference_store.ts +80 -0
  245. package/lib/ui_settings_init_script.ts +33 -0
  246. package/lib/use_check_in_form_collapsed.ts +18 -0
  247. package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
  248. package/lib/use_confirm_before_checkout.ts +18 -0
  249. package/lib/use_confirm_destructive_actions.ts +18 -0
  250. package/lib/use_duration_format.ts +17 -0
  251. package/lib/use_entry_list_sort.ts +17 -0
  252. package/lib/use_tag_filter_mode.ts +17 -0
  253. package/lib/use_time_format.ts +17 -0
  254. package/lib/use_timer_in_title.ts +18 -0
  255. package/lib/use_timer_show_seconds.ts +18 -0
  256. package/lib/use_week_starts_on.ts +17 -0
  257. package/lib/validate_entry_times.ts +12 -0
  258. package/lib/week_starts_on_to_index.ts +8 -0
  259. package/lib/write_active_sheet_preference.ts +28 -0
  260. package/lib/write_db.ts +20 -0
  261. package/lib/write_sheet_tag_filter.ts +20 -0
  262. package/lib/write_stored_compact_lists.ts +15 -0
  263. package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
  264. package/lib/write_stored_default_sheet_session_mode.ts +24 -0
  265. package/lib/write_stored_sheet_tag_filters.ts +18 -0
  266. package/lib/write_stored_theme.ts +12 -0
  267. package/next.config.ts +7 -0
  268. package/package.json +96 -0
  269. package/pnpm-workspace.yaml +7 -0
  270. package/postcss.config.mjs +7 -0
  271. package/public/file.svg +1 -0
  272. package/public/globe.svg +1 -0
  273. package/public/next.svg +1 -0
  274. package/public/vercel.svg +1 -0
  275. package/public/window.svg +1 -0
  276. package/tsconfig.json +34 -0
@@ -0,0 +1,340 @@
1
+ 'use client'
2
+
3
+ import { type ReactNode, useMemo, useState } from 'react'
4
+
5
+ import { ReportingDateRangePicker } from '@/components/reporting-date-range-picker'
6
+ import { ReportingSortControls } from '@/components/reporting-sort-controls'
7
+ import { TrackerTopbar } from '@/components/tracker-topbar'
8
+ import { build_reporting_stats } from '@/lib/build_reporting_stats'
9
+ import { default_reporting_sort_preference } from '@/lib/preferences/default_reporting_sort_preference'
10
+ import { format_duration } from '@/lib/format_duration'
11
+ import { get_date_range_ms_from_inputs } from '@/lib/get_date_range_ms_from_inputs'
12
+ import { get_initial_reporting_range_inputs } from '@/lib/get_initial_reporting_range_inputs'
13
+ import { parse_reporting_source_sheets } from '@/lib/parse_reporting_source_sheets'
14
+ import { sort_sheet_report_stats } from '@/lib/sort_sheet_report_stats'
15
+ import { use_duration_format } from '@/lib/use_duration_format'
16
+ import { use_week_starts_on } from '@/lib/use_week_starts_on'
17
+ import { week_starts_on_to_index } from '@/lib/week_starts_on_to_index'
18
+ import {
19
+ type ReportingDateRangeInputs,
20
+ type ReportingSourceSheet,
21
+ type SheetReportSort,
22
+ type SheetReportStats,
23
+ } from '@/lib/types/reporting'
24
+
25
+ interface ReportingViewProps {
26
+ source_sheets: ReportingSourceSheet[]
27
+ }
28
+
29
+ const empty_range: ReportingDateRangeInputs = {
30
+ from_date: '',
31
+ to_date: '',
32
+ }
33
+
34
+ /**
35
+ * Renders per-sheet time-tracking statistics.
36
+ */
37
+ export function ReportingView({ source_sheets }: ReportingViewProps) {
38
+ const duration_format = use_duration_format()
39
+ const week_starts_on = use_week_starts_on()
40
+ const [sort, set_sort] = useState<SheetReportSort>(() =>
41
+ default_reporting_sort_preference.read(),
42
+ )
43
+ const [range_inputs, set_range_inputs] = useState<ReportingDateRangeInputs>(
44
+ () => get_initial_reporting_range_inputs(undefined, week_starts_on),
45
+ )
46
+
47
+ const sheets = useMemo(
48
+ () => parse_reporting_source_sheets(source_sheets),
49
+ [source_sheets],
50
+ )
51
+ const date_range = useMemo(
52
+ () =>
53
+ get_date_range_ms_from_inputs(
54
+ range_inputs.from_date,
55
+ range_inputs.to_date,
56
+ ),
57
+ [range_inputs],
58
+ )
59
+ const range_is_partial =
60
+ (range_inputs.from_date.length > 0) !==
61
+ (range_inputs.to_date.length > 0)
62
+ const range_is_invalid =
63
+ range_is_partial ||
64
+ (range_inputs.from_date.length > 0 &&
65
+ range_inputs.to_date.length > 0 &&
66
+ date_range === null)
67
+
68
+ const week_starts_on_index = week_starts_on_to_index(week_starts_on)
69
+ const stats = useMemo(
70
+ () => build_reporting_stats(sheets, date_range, Date.now(), week_starts_on_index),
71
+ [sheets, date_range, week_starts_on_index],
72
+ )
73
+
74
+ const {
75
+ activeSheets,
76
+ grandAverageEntryMs,
77
+ grandTotalMs,
78
+ idleSheets,
79
+ periodTotals,
80
+ totalEntryCount,
81
+ } = stats
82
+ const sheet_count = activeSheets.length + idleSheets.length
83
+ const show_period_totals = date_range === null
84
+
85
+ const sorted_active_sheets = useMemo(
86
+ () => sort_sheet_report_stats(activeSheets, sort),
87
+ [activeSheets, sort],
88
+ )
89
+ const sorted_idle_sheets = useMemo(
90
+ () => sort_sheet_report_stats(idleSheets, sort),
91
+ [idleSheets, sort],
92
+ )
93
+
94
+ return (
95
+ <>
96
+ <TrackerTopbar breadcrumb={{ current: 'Reporting' }} />
97
+ <main className="flex flex-col items-center gap-6 px-5 pb-10 pt-6">
98
+ <header className="flex w-full max-w-2xl flex-col gap-3">
99
+ <h1 className="m-0 text-center text-[1.35rem] font-[650] tracking-tight">
100
+ Reporting
101
+ </h1>
102
+ <p className="m-0 max-w-md self-center text-center text-[0.9rem] leading-relaxed text-muted">
103
+ {date_range === null
104
+ ? 'Time tracked across all sheets.'
105
+ : 'Metrics filtered to the selected date range.'}
106
+ </p>
107
+ </header>
108
+
109
+ <ReportingDateRangePicker
110
+ range={range_inputs}
111
+ is_invalid={range_is_invalid}
112
+ on_range_change={set_range_inputs}
113
+ on_clear={() => set_range_inputs(empty_range)}
114
+ />
115
+
116
+ <section
117
+ className="grid w-full max-w-2xl grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4"
118
+ aria-label="Summary"
119
+ >
120
+ <SummaryCard
121
+ label={date_range === null ? 'Total tracked' : 'In range'}
122
+ value={format_duration(grandTotalMs, duration_format)}
123
+ />
124
+ <SummaryCard
125
+ label="Avg per entry"
126
+ value={format_duration(grandAverageEntryMs, duration_format)}
127
+ />
128
+ <SummaryCard label="Sheets" value={String(sheet_count)} />
129
+ <SummaryCard label="Entries" value={String(totalEntryCount)} />
130
+ </section>
131
+
132
+ {show_period_totals ? (
133
+ <section
134
+ className="grid w-full max-w-2xl grid-cols-1 gap-2 sm:grid-cols-3"
135
+ aria-label="Period totals"
136
+ >
137
+ <SummaryCard
138
+ label="Today"
139
+ value={format_duration(periodTotals.todayMs, duration_format)}
140
+ />
141
+ <SummaryCard
142
+ label="This week"
143
+ value={format_duration(periodTotals.weekMs, duration_format)}
144
+ />
145
+ <SummaryCard
146
+ label="This month"
147
+ value={format_duration(periodTotals.monthMs, duration_format)}
148
+ />
149
+ </section>
150
+ ) : null}
151
+
152
+ {sheet_count === 0 ? (
153
+ <p className="m-0 w-full max-w-2xl text-center text-[0.9rem] text-muted">
154
+ No sheets yet. Create a sheet on the tracker to start logging time.
155
+ </p>
156
+ ) : range_is_invalid ? (
157
+ <p className="m-0 w-full max-w-2xl text-center text-[0.9rem] text-muted">
158
+ Choose both dates to filter metrics, or clear the range to see all
159
+ time.
160
+ </p>
161
+ ) : (
162
+ <>
163
+ <ReportingSortControls sort={sort} on_sort_change={set_sort} />
164
+ {activeSheets.length === 0 ? (
165
+ <p className="m-0 w-full max-w-2xl text-center text-[0.9rem] text-muted">
166
+ {date_range === null
167
+ ? 'No tracked time yet. Check in on a sheet to see stats here.'
168
+ : 'No tracked time in this date range.'}
169
+ </p>
170
+ ) : (
171
+ <SheetStatsSection
172
+ title="Tracked sheets"
173
+ aria_label="Tracked sheet statistics"
174
+ >
175
+ {sorted_active_sheets.map((sheet) => (
176
+ <SheetStatsRow
177
+ key={sheet.sheetName}
178
+ sheet={sheet}
179
+ grand_total_ms={grandTotalMs}
180
+ duration_format={duration_format}
181
+ />
182
+ ))}
183
+ </SheetStatsSection>
184
+ )}
185
+ {idleSheets.length > 0 ? (
186
+ <SheetStatsSection
187
+ title={date_range === null ? 'Empty sheets' : 'Sheets in range'}
188
+ aria_label="Sheets without time in range"
189
+ muted
190
+ >
191
+ {sorted_idle_sheets.map((sheet) => (
192
+ <IdleSheetStatsRow
193
+ key={sheet.sheetName}
194
+ sheet={sheet}
195
+ in_range={date_range !== null}
196
+ />
197
+ ))}
198
+ </SheetStatsSection>
199
+ ) : null}
200
+ </>
201
+ )}
202
+ </main>
203
+ </>
204
+ )
205
+ }
206
+
207
+ interface SummaryCardProps {
208
+ label: string
209
+ value: string
210
+ }
211
+
212
+ /**
213
+ * Compact summary metric for the reporting header.
214
+ */
215
+ function SummaryCard({ label, value }: SummaryCardProps) {
216
+ return (
217
+ <div className="rounded-md border border-panel-border bg-panel p-3.5 shadow-sm">
218
+ <p className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
219
+ {label}
220
+ </p>
221
+ <p className="m-0 mt-1 text-[1.1rem] font-[650] tracking-tight">{value}</p>
222
+ </div>
223
+ )
224
+ }
225
+
226
+ interface SheetStatsSectionProps {
227
+ title: string
228
+ aria_label: string
229
+ muted?: boolean
230
+ children: ReactNode
231
+ }
232
+
233
+ /**
234
+ * Grouped list of sheet statistics with a section heading.
235
+ */
236
+ function SheetStatsSection({
237
+ title,
238
+ aria_label,
239
+ muted = false,
240
+ children,
241
+ }: SheetStatsSectionProps) {
242
+ return (
243
+ <section className="flex w-full max-w-2xl flex-col gap-2">
244
+ <h2
245
+ className={`m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] ${
246
+ muted ? 'text-muted' : 'text-foreground'
247
+ }`}
248
+ >
249
+ {title}
250
+ </h2>
251
+ <ul
252
+ className="m-0 flex list-none flex-col gap-2 p-0"
253
+ aria-label={aria_label}
254
+ >
255
+ {children}
256
+ </ul>
257
+ </section>
258
+ )
259
+ }
260
+
261
+ interface SheetStatsRowProps {
262
+ sheet: SheetReportStats
263
+ grand_total_ms: number
264
+ duration_format: import('@/lib/types/ui_preferences').DurationFormat
265
+ }
266
+
267
+ /**
268
+ * Single sheet row with duration, share, and entry count.
269
+ */
270
+ function SheetStatsRow({
271
+ sheet,
272
+ grand_total_ms,
273
+ duration_format,
274
+ }: SheetStatsRowProps) {
275
+ const share_percent =
276
+ grand_total_ms > 0 ? Math.round((sheet.totalMs / grand_total_ms) * 100) : 0
277
+ const bar_percent = grand_total_ms > 0 ? (sheet.totalMs / grand_total_ms) * 100 : 0
278
+
279
+ return (
280
+ <li className="rounded-md border border-panel-border bg-panel p-3.5 shadow-sm">
281
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
282
+ <h2 className="m-0 min-w-0 truncate text-[1rem] font-semibold">{sheet.sheetName}</h2>
283
+ <span className="shrink-0 font-mono text-[0.95rem] font-semibold text-accent">
284
+ {format_duration(sheet.totalMs, duration_format)}
285
+ </span>
286
+ </div>
287
+ <div
288
+ className="mt-2.5 h-1.5 overflow-hidden rounded-full bg-surface-raised"
289
+ role="presentation"
290
+ >
291
+ <div
292
+ className="h-full rounded-full bg-accent"
293
+ style={{ width: `${bar_percent}%` }}
294
+ />
295
+ </div>
296
+ <p className="m-0 mt-2 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.82rem] text-muted">
297
+ <span>{share_percent}% of total</span>
298
+ <span>
299
+ {sheet.entryCount} {sheet.entryCount === 1 ? 'entry' : 'entries'}
300
+ </span>
301
+ <span>
302
+ {sheet.entryCount === 0
303
+ ? 'No average'
304
+ : `${format_duration(sheet.averageEntryMs, duration_format)} avg`}
305
+ </span>
306
+ {sheet.hasActiveEntry ? <span className="text-accent">Timer running</span> : null}
307
+ </p>
308
+ </li>
309
+ )
310
+ }
311
+
312
+ interface IdleSheetStatsRowProps {
313
+ sheet: SheetReportStats
314
+ in_range: boolean
315
+ }
316
+
317
+ /**
318
+ * Compact row for sheets with no entries or no tracked time.
319
+ */
320
+ function IdleSheetStatsRow({ sheet, in_range }: IdleSheetStatsRowProps) {
321
+ const status_label = in_range
322
+ ? 'No time in range'
323
+ : sheet.entryCount === 0
324
+ ? 'No entries'
325
+ : 'No tracked time'
326
+
327
+ return (
328
+ <li className="rounded-md border border-dashed border-panel-border bg-surface-raised/60 px-3.5 py-2.5">
329
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
330
+ <h3 className="m-0 min-w-0 truncate text-[0.95rem] font-medium text-muted">
331
+ {sheet.sheetName}
332
+ </h3>
333
+ <span className="shrink-0 text-[0.82rem] text-muted">{status_label}</span>
334
+ </div>
335
+ {sheet.hasActiveEntry ? (
336
+ <p className="m-0 mt-1.5 text-[0.82rem] text-accent">Timer running</p>
337
+ ) : null}
338
+ </li>
339
+ )
340
+ }
@@ -0,0 +1,79 @@
1
+ 'use client'
2
+
3
+ import { type ReactNode } from 'react'
4
+
5
+ interface SettingRadioGroupOption<T extends string> {
6
+ value: T
7
+ label: string
8
+ description?: string
9
+ }
10
+
11
+ interface SettingRadioGroupProps<T extends string> {
12
+ name: string
13
+ legend: ReactNode
14
+ description?: ReactNode
15
+ value: T
16
+ options: SettingRadioGroupOption<T>[]
17
+ disabled?: boolean
18
+ on_change: (value: T) => void
19
+ }
20
+
21
+ /**
22
+ * Themed radio group for single-choice settings.
23
+ */
24
+ export function SettingRadioGroup<T extends string>({
25
+ name,
26
+ legend,
27
+ description,
28
+ value,
29
+ options,
30
+ disabled = false,
31
+ on_change,
32
+ }: SettingRadioGroupProps<T>) {
33
+ return (
34
+ <fieldset className="m-0 border-0 p-0">
35
+ <legend className="m-0 mb-1 text-[0.95rem] font-semibold">
36
+ {legend}
37
+ </legend>
38
+ {description !== undefined ? (
39
+ <p className="m-0 mb-2 text-[0.8rem] leading-snug text-muted">
40
+ {description}
41
+ </p>
42
+ ) : null}
43
+ <div className="flex flex-col gap-1.5">
44
+ {options.map((option) => {
45
+ const is_selected = option.value === value
46
+
47
+ return (
48
+ <label
49
+ key={option.value}
50
+ className={`flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-colors duration-150 ${
51
+ is_selected
52
+ ? 'border-accent-border bg-accent-soft'
53
+ : 'border-panel-border hover:bg-surface-hover'
54
+ }`}
55
+ >
56
+ <input
57
+ type="radio"
58
+ className="mt-1 shrink-0"
59
+ name={name}
60
+ value={option.value}
61
+ checked={is_selected}
62
+ disabled={disabled}
63
+ onChange={() => on_change(option.value)}
64
+ />
65
+ <span className="flex flex-col gap-0.5">
66
+ <span className="text-[0.9rem] font-semibold">{option.label}</span>
67
+ {option.description !== undefined ? (
68
+ <span className="text-[0.8rem] leading-snug text-muted">
69
+ {option.description}
70
+ </span>
71
+ ) : null}
72
+ </span>
73
+ </label>
74
+ )
75
+ })}
76
+ </div>
77
+ </fieldset>
78
+ )
79
+ }
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import { usePathname } from 'next/navigation'
5
+
6
+ interface SettingsNavItem {
7
+ href: string
8
+ label: string
9
+ match: (pathname: string) => boolean
10
+ }
11
+
12
+ const items: SettingsNavItem[] = [
13
+ {
14
+ href: '/settings',
15
+ label: 'General',
16
+ match: (pathname) => pathname === '/settings',
17
+ },
18
+ {
19
+ href: '/settings/display',
20
+ label: 'Display & layout',
21
+ match: (pathname) => pathname.startsWith('/settings/display'),
22
+ },
23
+ {
24
+ href: '/settings/data',
25
+ label: 'Data & backup',
26
+ match: (pathname) => pathname.startsWith('/settings/data'),
27
+ },
28
+ {
29
+ href: '/settings/tags',
30
+ label: 'Tag management',
31
+ match: (pathname) => pathname.startsWith('/settings/tags'),
32
+ },
33
+ ]
34
+
35
+ /**
36
+ * Sidebar navigation for the settings sub-pages.
37
+ */
38
+ export function SettingsNav() {
39
+ const pathname = usePathname() ?? '/settings'
40
+
41
+ return (
42
+ <nav aria-label="Settings sections" className="w-full">
43
+ <ul className="m-0 flex w-full list-none flex-col gap-0.5 p-0">
44
+ {items.map((item) => {
45
+ const is_active = item.match(pathname)
46
+
47
+ return (
48
+ <li key={item.href}>
49
+ <Link
50
+ href={item.href}
51
+ aria-current={is_active ? 'page' : undefined}
52
+ className={`block rounded-md px-3 py-2 text-[0.9rem] no-underline transition-colors duration-150 ${
53
+ is_active
54
+ ? 'bg-accent-soft text-foreground'
55
+ : 'text-muted hover:bg-surface-hover hover:text-foreground'
56
+ }`}
57
+ >
58
+ {item.label}
59
+ </Link>
60
+ </li>
61
+ )
62
+ })}
63
+ </ul>
64
+ </nav>
65
+ )
66
+ }
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from 'react'
2
+
3
+ import { SettingsNav } from '@/components/settings-nav'
4
+ import { SettingsSavedToast } from '@/components/settings-saved-toast'
5
+ import {
6
+ TrackerTopbar,
7
+ type TrackerTopbarBreadcrumb,
8
+ } from '@/components/tracker-topbar'
9
+
10
+ interface SettingsPageLayoutProps {
11
+ breadcrumb: TrackerTopbarBreadcrumb
12
+ title: string
13
+ description?: string
14
+ children: ReactNode
15
+ }
16
+
17
+ /**
18
+ * Shared chrome for settings sub-pages: topbar, sidebar nav, and main content.
19
+ */
20
+ export function SettingsPageLayout({
21
+ breadcrumb,
22
+ title,
23
+ description,
24
+ children,
25
+ }: SettingsPageLayoutProps) {
26
+ return (
27
+ <>
28
+ <SettingsSavedToast />
29
+ <TrackerTopbar breadcrumb={breadcrumb} />
30
+ <main className="mx-auto grid w-full max-w-[1120px] grid-cols-[minmax(12rem,16rem)_minmax(0,1fr)] gap-6 px-5 pb-10 pt-6 max-[860px]:grid-cols-1">
31
+ <aside className="flex flex-col gap-2">
32
+ <h2 className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
33
+ Settings
34
+ </h2>
35
+ <SettingsNav />
36
+ </aside>
37
+ <section className="flex min-w-0 flex-col gap-4">
38
+ <header className="flex flex-col gap-1">
39
+ <h1 className="m-0 text-[1.35rem] font-[650] tracking-tight">
40
+ {title}
41
+ </h1>
42
+ {description !== undefined ? (
43
+ <p className="m-0 text-[0.9rem] leading-relaxed text-muted">
44
+ {description}
45
+ </p>
46
+ ) : null}
47
+ </header>
48
+ {children}
49
+ </section>
50
+ </main>
51
+ </>
52
+ )
53
+ }
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import {
6
+ SETTINGS_SAVED_DEFAULT_MESSAGE,
7
+ subscribe_settings_saved,
8
+ } from '@/lib/notify_settings_saved'
9
+
10
+ const toast_visible_ms = 2800
11
+
12
+ /**
13
+ * Fixed toast shown when a settings preference is persisted.
14
+ */
15
+ export function SettingsSavedToast() {
16
+ const [message, set_message] = useState<string | null>(null)
17
+
18
+ useEffect(() => {
19
+ let hide_timer: ReturnType<typeof setTimeout> | null = null
20
+
21
+ const unsubscribe = subscribe_settings_saved((next_message) => {
22
+ set_message(next_message)
23
+
24
+ if (hide_timer !== null) {
25
+ clearTimeout(hide_timer)
26
+ }
27
+
28
+ hide_timer = setTimeout(() => {
29
+ set_message(null)
30
+ hide_timer = null
31
+ }, toast_visible_ms)
32
+ })
33
+
34
+ return () => {
35
+ unsubscribe()
36
+
37
+ if (hide_timer !== null) {
38
+ clearTimeout(hide_timer)
39
+ }
40
+ }
41
+ }, [])
42
+
43
+ if (message === null) {
44
+ return null
45
+ }
46
+
47
+ return (
48
+ <div
49
+ role="status"
50
+ aria-live="polite"
51
+ aria-atomic="true"
52
+ className="pointer-events-none fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-md border border-accent-border bg-panel px-4 py-2.5 text-[0.88rem] font-medium text-foreground shadow-md transition-[opacity,transform] duration-200"
53
+ >
54
+ {message || SETTINGS_SAVED_DEFAULT_MESSAGE}
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,108 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ import { HamburgerIcon } from '@/components/hamburger-icon'
6
+
7
+ interface SheetActionsMenuProps {
8
+ sheet_name: string
9
+ is_pending: boolean
10
+ can_delete: boolean
11
+ on_rename: () => void
12
+ on_delete: () => void
13
+ }
14
+
15
+ const menu_item_class =
16
+ 'block w-full cursor-pointer rounded-[0.45rem] border-0 bg-transparent px-2.5 py-1.5 text-left font-inherit text-[0.85rem] text-inherit hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-55'
17
+
18
+ /**
19
+ * Hamburger menu for sheet actions such as rename.
20
+ */
21
+ export function SheetActionsMenu({
22
+ sheet_name,
23
+ is_pending,
24
+ can_delete,
25
+ on_rename,
26
+ on_delete,
27
+ }: SheetActionsMenuProps) {
28
+ const [is_open, set_is_open] = useState(false)
29
+ const menu_ref = useRef<HTMLDivElement>(null)
30
+
31
+ useEffect(() => {
32
+ if (!is_open) {
33
+ return
34
+ }
35
+
36
+ const handle_pointer_down = (event: PointerEvent): void => {
37
+ if (
38
+ menu_ref.current !== null &&
39
+ !menu_ref.current.contains(event.target as Node)
40
+ ) {
41
+ set_is_open(false)
42
+ }
43
+ }
44
+
45
+ document.addEventListener('pointerdown', handle_pointer_down)
46
+
47
+ return () => {
48
+ document.removeEventListener('pointerdown', handle_pointer_down)
49
+ }
50
+ }, [is_open])
51
+
52
+ const close_menu = (): void => {
53
+ set_is_open(false)
54
+ }
55
+
56
+ return (
57
+ <div className="relative shrink-0 self-center" ref={menu_ref}>
58
+ <button
59
+ type="button"
60
+ className="inline-flex cursor-pointer appearance-none items-center justify-center rounded-none border-0 bg-transparent p-0.5 text-muted shadow-none hover:opacity-75 focus-visible:outline-2 focus-visible:outline-input-focus-border focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-55"
61
+ aria-label={`Actions for sheet ${sheet_name}`}
62
+ aria-expanded={is_open}
63
+ aria-haspopup="menu"
64
+ disabled={is_pending}
65
+ onClick={() => set_is_open((open) => !open)}
66
+ >
67
+ <HamburgerIcon />
68
+ </button>
69
+ {is_open ? (
70
+ <ul
71
+ className="absolute right-0 top-full z-10 mt-1.5 min-w-56 list-none rounded-md border border-panel-border bg-panel p-1.5 shadow-md"
72
+ role="menu"
73
+ >
74
+ <li role="none">
75
+ <button
76
+ type="button"
77
+ className={menu_item_class}
78
+ role="menuitem"
79
+ disabled={is_pending}
80
+ onClick={() => {
81
+ close_menu()
82
+ on_rename()
83
+ }}
84
+ >
85
+ Rename
86
+ </button>
87
+ </li>
88
+ <li className="my-1 border-t border-panel-border" role="separator" aria-hidden="true" />
89
+ <li role="none">
90
+ <button
91
+ type="button"
92
+ className={`${menu_item_class} text-danger`}
93
+ role="menuitem"
94
+ disabled={is_pending || !can_delete}
95
+ title={can_delete ? undefined : 'Cannot delete the last sheet'}
96
+ onClick={() => {
97
+ close_menu()
98
+ on_delete()
99
+ }}
100
+ >
101
+ Delete sheet
102
+ </button>
103
+ </li>
104
+ </ul>
105
+ ) : null}
106
+ </div>
107
+ )
108
+ }