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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +1 -0
- package/README.md +36 -0
- package/app/api/backup/route.ts +39 -0
- package/app/api/entry/delete-bulk/route.ts +53 -0
- package/app/api/entry/move/route.ts +46 -0
- package/app/api/entry/move-bulk/route.ts +62 -0
- package/app/api/entry/route.ts +75 -0
- package/app/api/in/route.ts +38 -0
- package/app/api/note/route.ts +120 -0
- package/app/api/out/route.ts +31 -0
- package/app/api/sheet/route.ts +68 -0
- package/app/api/state/route.ts +16 -0
- package/app/api/tags/route.ts +75 -0
- package/app/color-palettes.css +260 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +140 -0
- package/app/layout.tsx +54 -0
- package/app/page.tsx +24 -0
- package/app/reporting/page.tsx +11 -0
- package/app/settings/data/page.tsx +9 -0
- package/app/settings/display/page.tsx +8 -0
- package/app/settings/page.tsx +12 -0
- package/app/settings/tags/page.tsx +13 -0
- package/bin/stt-ui.js +63 -0
- package/components/active-entry-panel.tsx +199 -0
- package/components/backup-restore-setting.tsx +168 -0
- package/components/check-in-form-collapsed-setting.tsx +44 -0
- package/components/check-in-form-collapsible.tsx +52 -0
- package/components/check-in-form.tsx +89 -0
- package/components/checkbox.tsx +75 -0
- package/components/checkout-button-group.tsx +73 -0
- package/components/chevron-icon.tsx +25 -0
- package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
- package/components/color-palette-setting.tsx +75 -0
- package/components/compact-lists-setting.tsx +42 -0
- package/components/confirm-before-checkout-setting.tsx +42 -0
- package/components/confirm-destructive-actions-setting.tsx +46 -0
- package/components/confirm-dialog-provider.tsx +71 -0
- package/components/confirm-dialog.tsx +90 -0
- package/components/data-settings-view.tsx +47 -0
- package/components/default-reporting-range-setting.tsx +56 -0
- package/components/default-reporting-sort-setting.tsx +45 -0
- package/components/default-sheet-session-setting.tsx +118 -0
- package/components/display-settings-view.tsx +75 -0
- package/components/duration-format-setting.tsx +40 -0
- package/components/entry-actions-menu.tsx +207 -0
- package/components/entry-edit-form.tsx +113 -0
- package/components/entry-list-bulk-bar.tsx +128 -0
- package/components/entry-list-sort-setting.tsx +41 -0
- package/components/entry-list.tsx +336 -0
- package/components/entry-notes-list.tsx +211 -0
- package/components/entry-tag-filter.tsx +99 -0
- package/components/format_datetime_hint.ts +8 -0
- package/components/format_time.ts +10 -0
- package/components/general-settings-view.tsx +40 -0
- package/components/hamburger-icon.tsx +21 -0
- package/components/note-edit-form.tsx +77 -0
- package/components/note-form.tsx +109 -0
- package/components/pencil-icon.tsx +21 -0
- package/components/reporting-date-range-picker.tsx +121 -0
- package/components/reporting-sort-controls.tsx +53 -0
- package/components/reporting-view.tsx +340 -0
- package/components/setting-radio-group.tsx +79 -0
- package/components/settings-nav.tsx +66 -0
- package/components/settings-page-layout.tsx +53 -0
- package/components/settings-saved-toast.tsx +57 -0
- package/components/sheet-actions-menu.tsx +108 -0
- package/components/sheet-sidebar.tsx +196 -0
- package/components/tag-autocomplete-input.tsx +183 -0
- package/components/tag-filter-mode-setting.tsx +47 -0
- package/components/tag-management-view.tsx +290 -0
- package/components/theme-mode-setting.tsx +44 -0
- package/components/theme-mode-system-listener.tsx +43 -0
- package/components/theme_switcher.tsx +38 -0
- package/components/time-format-setting.tsx +39 -0
- package/components/timer-in-title-setting.tsx +38 -0
- package/components/timer-show-seconds-setting.tsx +41 -0
- package/components/tracker-active-bar.tsx +76 -0
- package/components/tracker-app.tsx +338 -0
- package/components/tracker-breadcrumb.tsx +56 -0
- package/components/tracker-document-title.tsx +67 -0
- package/components/tracker-topbar.tsx +63 -0
- package/components/trash-icon.tsx +24 -0
- package/components/week-starts-on-setting.tsx +39 -0
- package/eslint.config.mjs +18 -0
- package/lib/add_note_to_entry.ts +65 -0
- package/lib/api_error_response.ts +10 -0
- package/lib/apply_accent_color.ts +12 -0
- package/lib/apply_color_palette.ts +12 -0
- package/lib/apply_compact_lists.ts +9 -0
- package/lib/apply_tag_autocomplete_selection.ts +26 -0
- package/lib/apply_theme.ts +8 -0
- package/lib/build_reporting_stats.ts +55 -0
- package/lib/build_resume_description.ts +15 -0
- package/lib/check_in_entry.ts +81 -0
- package/lib/check_out_entry.ts +75 -0
- package/lib/collect_known_tags.ts +22 -0
- package/lib/collect_tag_stats.ts +27 -0
- package/lib/collect_tags_from_entries.ts +35 -0
- package/lib/config.ts +9 -0
- package/lib/convert_json_db.ts +49 -0
- package/lib/delete_entries.ts +62 -0
- package/lib/delete_entry.ts +29 -0
- package/lib/delete_note_on_entry.ts +42 -0
- package/lib/delete_sheet.ts +30 -0
- package/lib/delete_tracker_action.ts +22 -0
- package/lib/edit_entry.ts +56 -0
- package/lib/edit_note_on_entry.ts +49 -0
- package/lib/ensure_dir_exists.ts +22 -0
- package/lib/entry_matches_tag_filter.ts +26 -0
- package/lib/fetch_tracker_state.ts +15 -0
- package/lib/filter_entries_by_tags.ts +20 -0
- package/lib/filter_known_tags.ts +20 -0
- package/lib/find_all_serialized_active_entries.ts +28 -0
- package/lib/find_serialized_active_entry.ts +12 -0
- package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
- package/lib/find_sheet_with_active_entry.ts +16 -0
- package/lib/format_display_tag.ts +6 -0
- package/lib/format_duration.ts +45 -0
- package/lib/gen_db.ts +43 -0
- package/lib/get_active_panel_class_name.ts +20 -0
- package/lib/get_average_entry_ms.ts +13 -0
- package/lib/get_button_class_name.ts +24 -0
- package/lib/get_check_out_confirm_dialog.ts +19 -0
- package/lib/get_clipped_entry_duration_ms.ts +18 -0
- package/lib/get_compact_lists_snapshot.ts +15 -0
- package/lib/get_date_range_ms_from_inputs.ts +31 -0
- package/lib/get_delete_entries_confirm_dialog.ts +21 -0
- package/lib/get_delete_entry_confirm_dialog.ts +19 -0
- package/lib/get_delete_note_confirm_dialog.ts +21 -0
- package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
- package/lib/get_entry_duration_ms.ts +14 -0
- package/lib/get_entry_row_key.ts +8 -0
- package/lib/get_initial_preferred_sheet_name.ts +34 -0
- package/lib/get_initial_reporting_range_inputs.ts +31 -0
- package/lib/get_input_class_name.ts +15 -0
- package/lib/get_merge_tags_confirm_dialog.ts +25 -0
- package/lib/get_period_range_ms.ts +43 -0
- package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
- package/lib/get_reporting_period_totals.ts +39 -0
- package/lib/get_reporting_stats.ts +25 -0
- package/lib/get_restore_db_confirm_dialog.ts +14 -0
- package/lib/get_running_entry_key.ts +8 -0
- package/lib/get_serialized_entries_total_ms.ts +10 -0
- package/lib/get_sheet.ts +14 -0
- package/lib/get_sheet_report_stats.ts +22 -0
- package/lib/get_sheet_report_stats_for_range.ts +46 -0
- package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
- package/lib/get_sheets_duration_in_range.ts +27 -0
- package/lib/get_tag_autocomplete_context.ts +32 -0
- package/lib/get_theme_snapshot.ts +16 -0
- package/lib/get_tracker_state.ts +67 -0
- package/lib/has_string_value.ts +6 -0
- package/lib/is_entry_in_day.ts +15 -0
- package/lib/is_idle_sheet_report.ts +8 -0
- package/lib/is_json_time_tracker_db.ts +14 -0
- package/lib/merge_tags_across_db.ts +79 -0
- package/lib/migrate_json_db.ts +56 -0
- package/lib/migrate_json_db_to_version_three.ts +51 -0
- package/lib/migrate_json_db_to_version_two.ts +50 -0
- package/lib/move_entries_to_sheet.ts +152 -0
- package/lib/move_entry_to_sheet.ts +82 -0
- package/lib/normalize_stored_tag.ts +16 -0
- package/lib/notify_settings_saved.ts +47 -0
- package/lib/parse_default_sheet_session_mode.ts +21 -0
- package/lib/parse_entry_from_input.ts +23 -0
- package/lib/parse_natural_language_date.ts +23 -0
- package/lib/parse_reporting_source_sheets.ts +22 -0
- package/lib/partition_sheet_report_stats.ts +30 -0
- package/lib/patch_tracker_action.ts +22 -0
- package/lib/persist_ui_preference.ts +18 -0
- package/lib/post_tracker_action.ts +22 -0
- package/lib/preferences/accent_color_preference.ts +21 -0
- package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
- package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
- package/lib/preferences/color_palette_preference.ts +21 -0
- package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
- package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
- package/lib/preferences/default_reporting_range_preference.ts +21 -0
- package/lib/preferences/default_reporting_sort_preference.ts +24 -0
- package/lib/preferences/duration_format_preference.ts +19 -0
- package/lib/preferences/entry_list_sort_preference.ts +21 -0
- package/lib/preferences/tag_filter_mode_preference.ts +18 -0
- package/lib/preferences/theme_mode_preference.ts +18 -0
- package/lib/preferences/time_format_preference.ts +18 -0
- package/lib/preferences/timer_in_title_preference.ts +18 -0
- package/lib/preferences/timer_show_seconds_preference.ts +19 -0
- package/lib/preferences/week_starts_on_preference.ts +19 -0
- package/lib/prompt_check_out_at.ts +17 -0
- package/lib/prompt_entry_note.ts +14 -0
- package/lib/prune_sheet_tag_filter.ts +27 -0
- package/lib/read_db.ts +49 -0
- package/lib/read_db_backup_contents.ts +22 -0
- package/lib/read_document_compact_lists.ts +12 -0
- package/lib/read_document_theme.ts +14 -0
- package/lib/read_sheet_tag_filter.ts +26 -0
- package/lib/read_stored_active_sheet.ts +14 -0
- package/lib/read_stored_compact_lists.ts +24 -0
- package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
- package/lib/read_stored_default_sheet_session_mode.ts +18 -0
- package/lib/read_stored_sheet_tag_filters.ts +28 -0
- package/lib/read_stored_theme.ts +18 -0
- package/lib/rename_sheet.ts +39 -0
- package/lib/rename_tag_across_db.ts +19 -0
- package/lib/resolve_active_sheet_name.ts +36 -0
- package/lib/resolve_session_preferred_sheet.ts +37 -0
- package/lib/resolve_theme.ts +18 -0
- package/lib/resolve_theme_mode_to_theme.ts +19 -0
- package/lib/restore_db_from_uploaded_json.ts +24 -0
- package/lib/serialize_entry.ts +27 -0
- package/lib/serialize_reporting_source_sheets.ts +19 -0
- package/lib/serialize_sheet_entries.ts +18 -0
- package/lib/set_accent_color.ts +12 -0
- package/lib/set_active_sheet.ts +18 -0
- package/lib/set_color_palette.ts +12 -0
- package/lib/set_compact_lists.ts +12 -0
- package/lib/set_default_sheet_fixed_name.ts +8 -0
- package/lib/set_default_sheet_session_mode.ts +11 -0
- package/lib/set_sheet_tag_filter.ts +13 -0
- package/lib/set_theme_mode.ts +19 -0
- package/lib/sheet_tag_filter_snapshots.ts +48 -0
- package/lib/sort_serialized_entries.ts +35 -0
- package/lib/sort_sheet_report_stats.ts +43 -0
- package/lib/subscribe_compact_lists.ts +25 -0
- package/lib/subscribe_sheet_tag_filters.ts +28 -0
- package/lib/subscribe_theme.ts +23 -0
- package/lib/sync_active_sheet_preference.ts +19 -0
- package/lib/tags_are_equal.ts +12 -0
- package/lib/theme_init_script.ts +11 -0
- package/lib/toggle_sheet_tag_filter.ts +28 -0
- package/lib/toggle_theme.ts +20 -0
- package/lib/types/confirm_dialog.ts +9 -0
- package/lib/types/data.ts +16 -0
- package/lib/types/generic_data.ts +25 -0
- package/lib/types/index.ts +2 -0
- package/lib/types/reporting.ts +59 -0
- package/lib/types/tag_management.ts +7 -0
- package/lib/types/theme.ts +3 -0
- package/lib/types/tracker_state.ts +39 -0
- package/lib/types/ui_preferences.ts +104 -0
- package/lib/types/ui_settings.ts +17 -0
- package/lib/ui_preference_store.ts +80 -0
- package/lib/ui_settings_init_script.ts +33 -0
- package/lib/use_check_in_form_collapsed.ts +18 -0
- package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
- package/lib/use_confirm_before_checkout.ts +18 -0
- package/lib/use_confirm_destructive_actions.ts +18 -0
- package/lib/use_duration_format.ts +17 -0
- package/lib/use_entry_list_sort.ts +17 -0
- package/lib/use_tag_filter_mode.ts +17 -0
- package/lib/use_time_format.ts +17 -0
- package/lib/use_timer_in_title.ts +18 -0
- package/lib/use_timer_show_seconds.ts +18 -0
- package/lib/use_week_starts_on.ts +17 -0
- package/lib/validate_entry_times.ts +12 -0
- package/lib/week_starts_on_to_index.ts +8 -0
- package/lib/write_active_sheet_preference.ts +28 -0
- package/lib/write_db.ts +20 -0
- package/lib/write_sheet_tag_filter.ts +20 -0
- package/lib/write_stored_compact_lists.ts +15 -0
- package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
- package/lib/write_stored_default_sheet_session_mode.ts +24 -0
- package/lib/write_stored_sheet_tag_filters.ts +18 -0
- package/lib/write_stored_theme.ts +12 -0
- package/next.config.ts +7 -0
- package/package.json +96 -0
- package/pnpm-workspace.yaml +7 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
|
|
6
|
+
import { SettingsPageLayout } from '@/components/settings-page-layout'
|
|
7
|
+
import { TagAutocompleteInput } from '@/components/tag-autocomplete-input'
|
|
8
|
+
import { format_display_tag } from '@/lib/format_display_tag'
|
|
9
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
10
|
+
import { get_input_class_name } from '@/lib/get_input_class_name'
|
|
11
|
+
import { get_merge_tags_confirm_dialog } from '@/lib/get_merge_tags_confirm_dialog'
|
|
12
|
+
import { type TagStat } from '@/lib/types/tag_management'
|
|
13
|
+
|
|
14
|
+
interface TagManagementViewProps {
|
|
15
|
+
initial_tags: TagStat[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manages renaming and merging tags across all time entries.
|
|
20
|
+
*/
|
|
21
|
+
export function TagManagementView({ initial_tags }: TagManagementViewProps) {
|
|
22
|
+
const { confirm } = use_confirm_dialog()
|
|
23
|
+
const [tags, set_tags] = useState<TagStat[]>(initial_tags)
|
|
24
|
+
const [selected_tags, set_selected_tags] = useState<Set<string>>(() => new Set())
|
|
25
|
+
const [rename_values, set_rename_values] = useState<Record<string, string>>({})
|
|
26
|
+
const [merge_target, set_merge_target] = useState('')
|
|
27
|
+
const [error, set_error] = useState<string | null>(null)
|
|
28
|
+
const [status_message, set_status_message] = useState<string | null>(null)
|
|
29
|
+
const [is_pending, set_is_pending] = useState(false)
|
|
30
|
+
|
|
31
|
+
const known_tag_names = useMemo(() => tags.map((tag) => tag.name), [tags])
|
|
32
|
+
|
|
33
|
+
const selected_tag_stats = tags.filter((tag) => selected_tags.has(tag.name))
|
|
34
|
+
const selected_entry_count = selected_tag_stats.reduce(
|
|
35
|
+
(total, tag) => total + tag.entryCount,
|
|
36
|
+
0,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const toggle_selected = (tag_name: string): void => {
|
|
40
|
+
set_selected_tags((previous) => {
|
|
41
|
+
const next = new Set(previous)
|
|
42
|
+
|
|
43
|
+
if (next.has(tag_name)) {
|
|
44
|
+
next.delete(tag_name)
|
|
45
|
+
} else {
|
|
46
|
+
next.add(tag_name)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return next
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const patch_tags = async (body: Record<string, unknown>): Promise<void> => {
|
|
54
|
+
const response = await fetch('/api/tags', {
|
|
55
|
+
method: 'PATCH',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const payload = (await response.json()) as { error?: string }
|
|
62
|
+
throw new Error(payload.error ?? 'Tag update failed')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = (await response.json()) as {
|
|
66
|
+
tags: TagStat[]
|
|
67
|
+
entries_updated: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
set_tags(result.tags)
|
|
71
|
+
set_selected_tags(new Set())
|
|
72
|
+
set_status_message(
|
|
73
|
+
result.entries_updated === 1
|
|
74
|
+
? 'Updated 1 entry.'
|
|
75
|
+
: `Updated ${result.entries_updated} entries.`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handle_rename = async (from_tag: string): Promise<void> => {
|
|
80
|
+
const to_tag = rename_values[from_tag]?.trim() ?? ''
|
|
81
|
+
|
|
82
|
+
if (to_tag.length === 0) {
|
|
83
|
+
set_error('Enter a new tag name.')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const source_stat = tags.find((tag) => tag.name === from_tag)
|
|
88
|
+
|
|
89
|
+
if (source_stat === undefined) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const target_stat = tags.find(
|
|
94
|
+
(tag) => format_display_tag(tag.name) === format_display_tag(to_tag),
|
|
95
|
+
)
|
|
96
|
+
const affected_entries =
|
|
97
|
+
target_stat !== undefined && target_stat.name !== from_tag
|
|
98
|
+
? source_stat.entryCount + target_stat.entryCount
|
|
99
|
+
: source_stat.entryCount
|
|
100
|
+
|
|
101
|
+
const confirmed = await confirm(
|
|
102
|
+
get_merge_tags_confirm_dialog([from_tag], to_tag, affected_entries),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if (!confirmed) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
set_is_pending(true)
|
|
110
|
+
set_error(null)
|
|
111
|
+
set_status_message(null)
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await patch_tags({
|
|
115
|
+
action: 'rename',
|
|
116
|
+
fromTag: from_tag,
|
|
117
|
+
toTag: to_tag,
|
|
118
|
+
})
|
|
119
|
+
set_rename_values((previous) => {
|
|
120
|
+
const next = { ...previous }
|
|
121
|
+
delete next[from_tag]
|
|
122
|
+
return next
|
|
123
|
+
})
|
|
124
|
+
} catch (rename_error: unknown) {
|
|
125
|
+
set_error(
|
|
126
|
+
rename_error instanceof Error ? rename_error.message : String(rename_error),
|
|
127
|
+
)
|
|
128
|
+
} finally {
|
|
129
|
+
set_is_pending(false)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const handle_merge = async (): Promise<void> => {
|
|
134
|
+
const source_tags = [...selected_tags]
|
|
135
|
+
|
|
136
|
+
if (source_tags.length < 2) {
|
|
137
|
+
set_error('Select at least two tags to merge.')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (merge_target.trim().length === 0) {
|
|
142
|
+
set_error('Enter a target tag for the merge.')
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const confirmed = await confirm(
|
|
147
|
+
get_merge_tags_confirm_dialog(
|
|
148
|
+
source_tags,
|
|
149
|
+
merge_target,
|
|
150
|
+
selected_entry_count,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if (!confirmed) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
set_is_pending(true)
|
|
159
|
+
set_error(null)
|
|
160
|
+
set_status_message(null)
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await patch_tags({
|
|
164
|
+
action: 'merge',
|
|
165
|
+
sourceTags: source_tags,
|
|
166
|
+
targetTag: merge_target,
|
|
167
|
+
})
|
|
168
|
+
set_merge_target('')
|
|
169
|
+
} catch (merge_error: unknown) {
|
|
170
|
+
set_error(
|
|
171
|
+
merge_error instanceof Error ? merge_error.message : String(merge_error),
|
|
172
|
+
)
|
|
173
|
+
} finally {
|
|
174
|
+
set_is_pending(false)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<SettingsPageLayout
|
|
180
|
+
breadcrumb={{
|
|
181
|
+
current: 'Tag management',
|
|
182
|
+
parent: { label: 'Settings', href: '/settings' },
|
|
183
|
+
}}
|
|
184
|
+
title="Tag management"
|
|
185
|
+
description="Rename or merge tags across every entry in your database."
|
|
186
|
+
>
|
|
187
|
+
|
|
188
|
+
<section className="rounded-md border border-panel-border bg-panel p-3.5 shadow-sm">
|
|
189
|
+
<h2 className="m-0 text-[0.95rem] font-semibold">Merge tags</h2>
|
|
190
|
+
<p className="m-0 mt-1 text-[0.8rem] leading-snug text-muted">
|
|
191
|
+
Select two or more tags below, then choose the tag they should become.
|
|
192
|
+
</p>
|
|
193
|
+
<label className="mt-3 flex flex-col gap-1 text-[0.82rem] text-muted">
|
|
194
|
+
Target tag
|
|
195
|
+
<TagAutocompleteInput
|
|
196
|
+
id="merge-target-tag"
|
|
197
|
+
value={merge_target}
|
|
198
|
+
known_tags={known_tag_names}
|
|
199
|
+
placeholder="e.g. @project"
|
|
200
|
+
disabled={is_pending}
|
|
201
|
+
on_change={set_merge_target}
|
|
202
|
+
/>
|
|
203
|
+
</label>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className={`${get_button_class_name('primary', 'small')} mt-3`}
|
|
207
|
+
disabled={is_pending || selected_tags.size < 2}
|
|
208
|
+
onClick={() => void handle_merge()}
|
|
209
|
+
>
|
|
210
|
+
Merge {selected_tags.size} tags
|
|
211
|
+
</button>
|
|
212
|
+
</section>
|
|
213
|
+
|
|
214
|
+
{tags.length === 0 ? (
|
|
215
|
+
<p className="m-0 text-[0.9rem] text-muted">
|
|
216
|
+
No tags yet. Add @tags when you check in to an entry.
|
|
217
|
+
</p>
|
|
218
|
+
) : (
|
|
219
|
+
<ul
|
|
220
|
+
className="m-0 flex list-none flex-col gap-2 p-0"
|
|
221
|
+
aria-label="Tags"
|
|
222
|
+
>
|
|
223
|
+
{tags.map((tag) => (
|
|
224
|
+
<li
|
|
225
|
+
key={tag.name}
|
|
226
|
+
className="rounded-md border border-panel-border bg-panel px-3.5 py-2.5 shadow-sm"
|
|
227
|
+
>
|
|
228
|
+
<form
|
|
229
|
+
className="flex flex-wrap items-center gap-x-3 gap-y-2"
|
|
230
|
+
onSubmit={(event) => {
|
|
231
|
+
event.preventDefault()
|
|
232
|
+
void handle_rename(tag.name)
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<input
|
|
236
|
+
type="checkbox"
|
|
237
|
+
className="shrink-0"
|
|
238
|
+
checked={selected_tags.has(tag.name)}
|
|
239
|
+
disabled={is_pending}
|
|
240
|
+
aria-label={`Select ${format_display_tag(tag.name)}`}
|
|
241
|
+
onChange={() => toggle_selected(tag.name)}
|
|
242
|
+
/>
|
|
243
|
+
<div className="flex min-w-0 shrink-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
244
|
+
<span className="font-semibold leading-tight">
|
|
245
|
+
{format_display_tag(tag.name)}
|
|
246
|
+
</span>
|
|
247
|
+
<span className="text-[0.82rem] text-muted">
|
|
248
|
+
{tag.entryCount}{' '}
|
|
249
|
+
{tag.entryCount === 1 ? 'entry' : 'entries'}
|
|
250
|
+
</span>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="ml-auto flex min-w-[min(100%,14rem)] flex-1 basis-56 items-center justify-end gap-2 sm:max-w-xs">
|
|
253
|
+
<input
|
|
254
|
+
className={get_input_class_name('compact')}
|
|
255
|
+
value={rename_values[tag.name] ?? ''}
|
|
256
|
+
placeholder="Rename to…"
|
|
257
|
+
aria-label={`Rename ${format_display_tag(tag.name)}`}
|
|
258
|
+
disabled={is_pending}
|
|
259
|
+
onChange={(event) =>
|
|
260
|
+
set_rename_values((previous) => ({
|
|
261
|
+
...previous,
|
|
262
|
+
[tag.name]: event.target.value,
|
|
263
|
+
}))
|
|
264
|
+
}
|
|
265
|
+
/>
|
|
266
|
+
<button
|
|
267
|
+
type="submit"
|
|
268
|
+
className={`${get_button_class_name('ghost', 'small')} shrink-0`}
|
|
269
|
+
disabled={
|
|
270
|
+
is_pending || (rename_values[tag.name]?.trim().length ?? 0) === 0
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
Rename
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
</form>
|
|
277
|
+
</li>
|
|
278
|
+
))}
|
|
279
|
+
</ul>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{status_message !== null ? (
|
|
283
|
+
<p className="m-0 text-[0.9rem] text-accent">{status_message}</p>
|
|
284
|
+
) : null}
|
|
285
|
+
{error !== null ? (
|
|
286
|
+
<p className="m-0 text-[0.9rem] text-danger">{error}</p>
|
|
287
|
+
) : null}
|
|
288
|
+
</SettingsPageLayout>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { SettingRadioGroup } from '@/components/setting-radio-group'
|
|
6
|
+
import { theme_mode_preference } from '@/lib/preferences/theme_mode_preference'
|
|
7
|
+
import { notify_settings_saved } from '@/lib/notify_settings_saved'
|
|
8
|
+
import { set_theme_mode } from '@/lib/set_theme_mode'
|
|
9
|
+
import { type ThemeMode } from '@/lib/types/ui_preferences'
|
|
10
|
+
|
|
11
|
+
const options: { value: ThemeMode; label: string; description: string }[] = [
|
|
12
|
+
{ value: 'light', label: 'Light', description: 'Always use the light theme.' },
|
|
13
|
+
{ value: 'dark', label: 'Dark', description: 'Always use the dark theme.' },
|
|
14
|
+
{
|
|
15
|
+
value: 'system',
|
|
16
|
+
label: 'System',
|
|
17
|
+
description: 'Match the operating system preference.',
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Setting: light / dark / system theme preference.
|
|
23
|
+
*/
|
|
24
|
+
export function ThemeModeSetting() {
|
|
25
|
+
const mode = useSyncExternalStore(
|
|
26
|
+
theme_mode_preference.subscribe,
|
|
27
|
+
theme_mode_preference.get_snapshot,
|
|
28
|
+
theme_mode_preference.get_server_snapshot,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<SettingRadioGroup<ThemeMode>
|
|
33
|
+
name="theme-mode"
|
|
34
|
+
legend="Light / dark mode"
|
|
35
|
+
description="Choose light, dark, or match the system. The topbar toggle flips light and dark."
|
|
36
|
+
value={mode}
|
|
37
|
+
options={options}
|
|
38
|
+
on_change={(mode) => {
|
|
39
|
+
set_theme_mode(mode)
|
|
40
|
+
notify_settings_saved()
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
import { apply_theme } from '@/lib/apply_theme'
|
|
6
|
+
import { notify_theme_subscribers } from '@/lib/subscribe_theme'
|
|
7
|
+
import { resolve_theme_mode_to_theme } from '@/lib/resolve_theme_mode_to_theme'
|
|
8
|
+
import { theme_mode_preference } from '@/lib/preferences/theme_mode_preference'
|
|
9
|
+
import { write_stored_theme } from '@/lib/write_stored_theme'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Listens to OS-level theme changes and re-applies the theme when the
|
|
13
|
+
* user's mode preference is "system".
|
|
14
|
+
*/
|
|
15
|
+
export function ThemeModeSystemListener() {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (typeof window === 'undefined') {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const media = window.matchMedia('(prefers-color-scheme: light)')
|
|
22
|
+
|
|
23
|
+
const handle_change = (): void => {
|
|
24
|
+
if (theme_mode_preference.read() !== 'system') {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resolved = resolve_theme_mode_to_theme('system')
|
|
29
|
+
|
|
30
|
+
apply_theme(resolved)
|
|
31
|
+
write_stored_theme(resolved)
|
|
32
|
+
notify_theme_subscribers()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
media.addEventListener('change', handle_change)
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
media.removeEventListener('change', handle_change)
|
|
39
|
+
}
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { get_theme_server_snapshot, get_theme_snapshot } from '@/lib/get_theme_snapshot'
|
|
6
|
+
import { subscribe_theme } from '@/lib/subscribe_theme'
|
|
7
|
+
import { toggle_theme } from '@/lib/toggle_theme'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Toggles between light and dark themes.
|
|
11
|
+
*/
|
|
12
|
+
export function ThemeSwitcher() {
|
|
13
|
+
const theme = useSyncExternalStore(
|
|
14
|
+
subscribe_theme,
|
|
15
|
+
get_theme_snapshot,
|
|
16
|
+
get_theme_server_snapshot,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const active_label = theme === 'dark' ? 'Dark' : 'Light'
|
|
20
|
+
const switch_label =
|
|
21
|
+
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-panel-border bg-ghost-bg px-3 py-1.5 font-inherit text-[0.85rem] font-semibold text-inherit hover:bg-surface-hover disabled:cursor-wait disabled:opacity-60"
|
|
27
|
+
onClick={toggle_theme}
|
|
28
|
+
aria-label={`${active_label} theme. ${switch_label}`}
|
|
29
|
+
title={switch_label}
|
|
30
|
+
suppressHydrationWarning
|
|
31
|
+
>
|
|
32
|
+
<span className="text-base leading-none" aria-hidden="true">
|
|
33
|
+
{theme === 'dark' ? '☾' : '☀'}
|
|
34
|
+
</span>
|
|
35
|
+
<span suppressHydrationWarning>{active_label}</span>
|
|
36
|
+
</button>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { SettingRadioGroup } from '@/components/setting-radio-group'
|
|
6
|
+
import { time_format_preference } from '@/lib/preferences/time_format_preference'
|
|
7
|
+
import { persist_ui_preference } from '@/lib/persist_ui_preference'
|
|
8
|
+
import { type TimeFormat } from '@/lib/types/ui_preferences'
|
|
9
|
+
|
|
10
|
+
const options: { value: TimeFormat; label: string; description: string }[] = [
|
|
11
|
+
{ value: '12h', label: '12-hour', description: 'e.g. 6:34 PM' },
|
|
12
|
+
{ value: '24h', label: '24-hour', description: 'e.g. 18:34' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const set_time_format = (value: TimeFormat): void => {
|
|
16
|
+
persist_ui_preference(time_format_preference, value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Setting: 12-hour vs 24-hour time display.
|
|
21
|
+
*/
|
|
22
|
+
export function TimeFormatSetting() {
|
|
23
|
+
const value = useSyncExternalStore(
|
|
24
|
+
time_format_preference.subscribe,
|
|
25
|
+
time_format_preference.get_snapshot,
|
|
26
|
+
time_format_preference.get_server_snapshot,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<SettingRadioGroup<TimeFormat>
|
|
31
|
+
name="time-format"
|
|
32
|
+
legend="Time format"
|
|
33
|
+
description="Used for entry start/end times and notes timestamps."
|
|
34
|
+
value={value}
|
|
35
|
+
options={options}
|
|
36
|
+
on_change={set_time_format}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { timer_in_title_preference } from '@/lib/preferences/timer_in_title_preference'
|
|
6
|
+
import { persist_ui_preference } from '@/lib/persist_ui_preference'
|
|
7
|
+
|
|
8
|
+
const set_timer_in_title = (enabled: boolean): void => {
|
|
9
|
+
persist_ui_preference(timer_in_title_preference, enabled ? 'true' : 'false')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Setting: show the live timer in the browser tab title.
|
|
14
|
+
*/
|
|
15
|
+
export function TimerInTitleSetting() {
|
|
16
|
+
const value = useSyncExternalStore(
|
|
17
|
+
timer_in_title_preference.subscribe,
|
|
18
|
+
timer_in_title_preference.get_snapshot,
|
|
19
|
+
timer_in_title_preference.get_server_snapshot,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<label className="flex w-full cursor-pointer items-center gap-2.5">
|
|
24
|
+
<input
|
|
25
|
+
type="checkbox"
|
|
26
|
+
className="shrink-0"
|
|
27
|
+
checked={value === 'true'}
|
|
28
|
+
onChange={(event) => set_timer_in_title(event.target.checked)}
|
|
29
|
+
/>
|
|
30
|
+
<span className="flex flex-col gap-0.5">
|
|
31
|
+
<span className="text-[0.95rem] font-semibold">Timer in tab title</span>
|
|
32
|
+
<span className="text-[0.8rem] leading-snug text-muted">
|
|
33
|
+
Show elapsed time in the browser tab while a timer is running.
|
|
34
|
+
</span>
|
|
35
|
+
</span>
|
|
36
|
+
</label>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { timer_show_seconds_preference } from '@/lib/preferences/timer_show_seconds_preference'
|
|
6
|
+
import { persist_ui_preference } from '@/lib/persist_ui_preference'
|
|
7
|
+
|
|
8
|
+
const set_timer_show_seconds = (enabled: boolean): void => {
|
|
9
|
+
persist_ui_preference(
|
|
10
|
+
timer_show_seconds_preference,
|
|
11
|
+
enabled ? 'true' : 'false',
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Setting: show seconds on the active timer display.
|
|
17
|
+
*/
|
|
18
|
+
export function TimerShowSecondsSetting() {
|
|
19
|
+
const value = useSyncExternalStore(
|
|
20
|
+
timer_show_seconds_preference.subscribe,
|
|
21
|
+
timer_show_seconds_preference.get_snapshot,
|
|
22
|
+
timer_show_seconds_preference.get_server_snapshot,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<label className="flex w-full cursor-pointer items-center gap-2.5">
|
|
27
|
+
<input
|
|
28
|
+
type="checkbox"
|
|
29
|
+
className="shrink-0"
|
|
30
|
+
checked={value === 'true'}
|
|
31
|
+
onChange={(event) => set_timer_show_seconds(event.target.checked)}
|
|
32
|
+
/>
|
|
33
|
+
<span className="flex flex-col gap-0.5">
|
|
34
|
+
<span className="text-[0.95rem] font-semibold">Show seconds on timer</span>
|
|
35
|
+
<span className="text-[0.8rem] leading-snug text-muted">
|
|
36
|
+
Include seconds on the live active timer (humanized duration format).
|
|
37
|
+
</span>
|
|
38
|
+
</span>
|
|
39
|
+
</label>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ActiveEntryPanel } from '@/components/active-entry-panel'
|
|
4
|
+
import { type EntryEditFormValues } from '@/components/entry-edit-form'
|
|
5
|
+
import {
|
|
6
|
+
type SerializedEntry,
|
|
7
|
+
type SerializedSheet,
|
|
8
|
+
} from '@/lib/types/tracker_state'
|
|
9
|
+
|
|
10
|
+
interface TrackerActiveBarProps {
|
|
11
|
+
active_entry: SerializedEntry | null
|
|
12
|
+
sheets: SerializedSheet[]
|
|
13
|
+
is_pending: boolean
|
|
14
|
+
on_check_out: (at?: string) => void
|
|
15
|
+
on_delete: () => void
|
|
16
|
+
on_edit: (values: EntryEditFormValues) => void
|
|
17
|
+
on_move: (target_sheet_name: string) => void
|
|
18
|
+
on_add_note: (text: string, at?: string) => void
|
|
19
|
+
on_edit_note: (timestamp: string, text: string) => void
|
|
20
|
+
on_delete_note: (timestamp: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const section_label_class =
|
|
24
|
+
'text-[0.72rem] font-semibold uppercase tracking-[0.04em] text-muted whitespace-nowrap'
|
|
25
|
+
|
|
26
|
+
const tracking_pill_class =
|
|
27
|
+
'shrink-0 rounded-full bg-accent px-2 py-0.5 text-[0.68rem] font-bold uppercase leading-none tracking-wider text-accent-text-on'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Full-width header region for the active sheet and running entry controls.
|
|
31
|
+
*/
|
|
32
|
+
export function TrackerActiveBar({
|
|
33
|
+
active_entry,
|
|
34
|
+
sheets,
|
|
35
|
+
is_pending,
|
|
36
|
+
on_check_out,
|
|
37
|
+
on_delete,
|
|
38
|
+
on_edit,
|
|
39
|
+
on_move,
|
|
40
|
+
on_add_note,
|
|
41
|
+
on_edit_note,
|
|
42
|
+
on_delete_note,
|
|
43
|
+
}: TrackerActiveBarProps) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="w-full border-b border-panel-border bg-[color-mix(in_srgb,var(--accent-soft)_55%,var(--panel))]">
|
|
46
|
+
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-3 px-5 py-3.5 max-[860px]:gap-2.5 max-[860px]:py-3">
|
|
47
|
+
{active_entry !== null ? (
|
|
48
|
+
<div className="flex flex-col gap-2">
|
|
49
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
50
|
+
<span className={`${section_label_class} truncate`}>
|
|
51
|
+
Sheet {active_entry.sheetName}
|
|
52
|
+
</span>
|
|
53
|
+
<span className={tracking_pill_class}>Tracking</span>
|
|
54
|
+
</div>
|
|
55
|
+
<ActiveEntryPanel
|
|
56
|
+
key={`${active_entry.sheetName}-${active_entry.id}`}
|
|
57
|
+
entry={active_entry}
|
|
58
|
+
sheets={sheets}
|
|
59
|
+
in_bar
|
|
60
|
+
is_pending={is_pending}
|
|
61
|
+
on_check_out={on_check_out}
|
|
62
|
+
on_delete={on_delete}
|
|
63
|
+
on_edit={on_edit}
|
|
64
|
+
on_move={on_move}
|
|
65
|
+
on_add_note={on_add_note}
|
|
66
|
+
on_edit_note={on_edit_note}
|
|
67
|
+
on_delete_note={on_delete_note}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
) : (
|
|
71
|
+
<p className="m-0 text-[0.85rem] leading-tight text-muted">Not tracking</p>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|