@nightkatana/kronosys-app 1.0.0-beta.0
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/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
5
|
+
import { normalizeSessionEndReasonKind, normalizeSessionEndReasonNote } from "@/lib/sessionEndReason";
|
|
6
|
+
|
|
7
|
+
const REASON_OPTIONS = ["", "planned", "early", "overrun", "other"] as const;
|
|
8
|
+
|
|
9
|
+
export function SessionEndReasonEditor({
|
|
10
|
+
t,
|
|
11
|
+
radioGroupName,
|
|
12
|
+
sessionId,
|
|
13
|
+
initialKind,
|
|
14
|
+
initialNote,
|
|
15
|
+
post,
|
|
16
|
+
}: {
|
|
17
|
+
t: DashboardStrings;
|
|
18
|
+
/** Nom du groupe de boutons radio (unique par instance sur la page). */
|
|
19
|
+
radioGroupName: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
initialKind?: string;
|
|
22
|
+
initialNote?: string;
|
|
23
|
+
post: (body: Record<string, unknown>) => Promise<unknown>;
|
|
24
|
+
}) {
|
|
25
|
+
const [kind, setKind] = useState("");
|
|
26
|
+
const [note, setNote] = useState("");
|
|
27
|
+
const [saving, setSaving] = useState(false);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setKind(normalizeSessionEndReasonKind(initialKind) || "");
|
|
31
|
+
setNote(typeof initialNote === "string" ? initialNote : "");
|
|
32
|
+
}, [sessionId, initialKind, initialNote]);
|
|
33
|
+
|
|
34
|
+
const normalizedInitialKind = useMemo(() => normalizeSessionEndReasonKind(initialKind) || "", [initialKind]);
|
|
35
|
+
const normalizedInitialNote = useMemo(() => normalizeSessionEndReasonNote(initialNote), [initialNote]);
|
|
36
|
+
const currentKind = normalizeSessionEndReasonKind(kind) || "";
|
|
37
|
+
const currentNote = normalizeSessionEndReasonNote(note);
|
|
38
|
+
const dirty = currentKind !== normalizedInitialKind || currentNote !== normalizedInitialNote;
|
|
39
|
+
|
|
40
|
+
const optionLabels = useMemo(
|
|
41
|
+
() =>
|
|
42
|
+
({
|
|
43
|
+
"": t.sessionEndReasonSkip,
|
|
44
|
+
planned: t.sessionEndReasonPlanned,
|
|
45
|
+
early: t.sessionEndReasonEarly,
|
|
46
|
+
overrun: t.sessionEndReasonOverrun,
|
|
47
|
+
other: t.sessionEndReasonOther,
|
|
48
|
+
}) as Record<(typeof REASON_OPTIONS)[number], string>,
|
|
49
|
+
[t]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const onSave = async () => {
|
|
53
|
+
setSaving(true);
|
|
54
|
+
try {
|
|
55
|
+
await post({
|
|
56
|
+
type: "setSessionEndReason",
|
|
57
|
+
sessionId,
|
|
58
|
+
sessionEndReasonKind: currentKind,
|
|
59
|
+
sessionEndReasonNote: currentNote,
|
|
60
|
+
});
|
|
61
|
+
} finally {
|
|
62
|
+
setSaving(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-3 text-left text-[0.75rem] leading-snug text-zinc-700 dark:text-zinc-300">
|
|
68
|
+
<p className="text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400">{t.sessionEndReasonEditHint}</p>
|
|
69
|
+
<fieldset className="space-y-2 border-0 p-0">
|
|
70
|
+
<legend className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
|
|
71
|
+
{t.sessionEndReasonFieldsetLegend}
|
|
72
|
+
</legend>
|
|
73
|
+
{REASON_OPTIONS.map((value) => (
|
|
74
|
+
<label
|
|
75
|
+
key={value || "skip"}
|
|
76
|
+
className="flex cursor-pointer items-start gap-2 rounded-md py-0.5 pr-1 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50"
|
|
77
|
+
>
|
|
78
|
+
<input
|
|
79
|
+
type="radio"
|
|
80
|
+
name={radioGroupName}
|
|
81
|
+
className="mt-1 size-4 shrink-0 border-zinc-300 text-violet-600 focus:ring-violet-500/50 dark:border-zinc-600"
|
|
82
|
+
checked={currentKind === value}
|
|
83
|
+
onChange={() => setKind(value)}
|
|
84
|
+
/>
|
|
85
|
+
<span className="leading-snug">{optionLabels[value]}</span>
|
|
86
|
+
</label>
|
|
87
|
+
))}
|
|
88
|
+
</fieldset>
|
|
89
|
+
<label className="block">
|
|
90
|
+
<span className="sr-only">{t.sessionEndReasonNoteAria}</span>
|
|
91
|
+
<textarea
|
|
92
|
+
className="mt-1 w-full min-h-[4rem] resize-y rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-violet-500/70 focus:outline-none focus:ring-2 focus:ring-violet-500/25 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
|
93
|
+
rows={3}
|
|
94
|
+
maxLength={500}
|
|
95
|
+
value={note}
|
|
96
|
+
onChange={(e) => setNote(e.target.value)}
|
|
97
|
+
placeholder={t.sessionEndReasonNotePlaceholder}
|
|
98
|
+
aria-label={t.sessionEndReasonNoteAria}
|
|
99
|
+
/>
|
|
100
|
+
</label>
|
|
101
|
+
<div className="flex justify-end pt-1">
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="rounded-lg border border-violet-300 bg-violet-50 px-3 py-1.5 text-sm font-medium text-violet-900 transition hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-violet-700/80 dark:bg-violet-950/50 dark:text-violet-100 dark:hover:bg-violet-950/80"
|
|
105
|
+
disabled={!dirty || saving}
|
|
106
|
+
aria-label={t.sessionEndReasonSaveAria}
|
|
107
|
+
onClick={() => void onSave()}
|
|
108
|
+
>
|
|
109
|
+
{saving ? t.sessionEndReasonSaving : t.sessionEndReasonSaveBtn}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Archive, Circle, ExternalLink, Loader2, Square, Trash2, UploadCloud } from "lucide-react";
|
|
4
|
+
import { sessionTaskCountNoun, type DashboardStrings, type Lang } from "@/lib/dashboardCopy";
|
|
5
|
+
import { DEFAULT_DASHBOARD_TIME_ZONE, isValidIanaTimeZone } from "@/lib/dashboardTimeZone";
|
|
6
|
+
import { formatIsoInstantShort } from "@/lib/formatIsoShort";
|
|
7
|
+
import { sessionWallClockMinutes, type LooseSession } from "@/lib/reportingAggregate";
|
|
8
|
+
import { formatDuration } from "@/lib/taskParsing";
|
|
9
|
+
|
|
10
|
+
export type SessionListEntry = {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
sessionName?: string;
|
|
13
|
+
savedAt?: string;
|
|
14
|
+
/** Horodatage immuable de création de la session ; repli : `startAt` pour les anciennes données. */
|
|
15
|
+
createdAt?: string | null;
|
|
16
|
+
startAt?: string | null;
|
|
17
|
+
endAt?: string | null;
|
|
18
|
+
sessionDurationMinutes?: number;
|
|
19
|
+
/** Catégorie enregistrée à la clôture (session terminée). */
|
|
20
|
+
sessionEndReasonKind?: string;
|
|
21
|
+
/** Précision libre à la clôture. */
|
|
22
|
+
sessionEndReasonNote?: string;
|
|
23
|
+
tasks?: unknown[];
|
|
24
|
+
activeTasks?: unknown[];
|
|
25
|
+
activeTask?: unknown | null;
|
|
26
|
+
mongoPushedAt?: string;
|
|
27
|
+
mongoLastPushedSavedAt?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function taskCount(s: SessionListEntry): number {
|
|
31
|
+
const listed = s.tasks?.length ?? 0;
|
|
32
|
+
const nActive =
|
|
33
|
+
Array.isArray(s.activeTasks) && s.activeTasks.length > 0
|
|
34
|
+
? s.activeTasks.length
|
|
35
|
+
: s.activeTask
|
|
36
|
+
? 1
|
|
37
|
+
: 0;
|
|
38
|
+
return listed + nActive;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sessionMongoPushState(sess: SessionListEntry): "never" | "synced" | "dirty" {
|
|
42
|
+
const saved = sess.savedAt ?? "";
|
|
43
|
+
const lastPush = sess.mongoLastPushedSavedAt;
|
|
44
|
+
if (!lastPush) {
|
|
45
|
+
return "never";
|
|
46
|
+
}
|
|
47
|
+
return lastPush === saved ? "synced" : "dirty";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sortSessions(sessions: SessionListEntry[]): SessionListEntry[] {
|
|
51
|
+
return [...sessions].sort((a, b) => {
|
|
52
|
+
const timeA = Date.parse(a.savedAt || a.createdAt || a.startAt || "") || 0;
|
|
53
|
+
const timeB = Date.parse(b.savedAt || b.createdAt || b.startAt || "") || 0;
|
|
54
|
+
return timeB - timeA;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Zone fixe (28×28) pour aligner les icônes sur la ligne date / tâches. */
|
|
59
|
+
const sessionRowActionBase =
|
|
60
|
+
"inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-transparent hover:border-zinc-400 hover:bg-zinc-200/90 dark:hover:border-zinc-600 dark:hover:bg-zinc-800/80";
|
|
61
|
+
|
|
62
|
+
const sessionRowActionIconSize = 16;
|
|
63
|
+
|
|
64
|
+
export function SessionListPanel({
|
|
65
|
+
sessions,
|
|
66
|
+
liveSessionId,
|
|
67
|
+
selectedSessionId,
|
|
68
|
+
t,
|
|
69
|
+
onSelectSession,
|
|
70
|
+
onOpenSessionInNewTab,
|
|
71
|
+
onEndLiveSession,
|
|
72
|
+
onArchiveSession,
|
|
73
|
+
onDeleteSession,
|
|
74
|
+
onOpenArchives,
|
|
75
|
+
archivedCount = 0,
|
|
76
|
+
lang,
|
|
77
|
+
displayTimeZone = DEFAULT_DASHBOARD_TIME_ZONE,
|
|
78
|
+
use24HourClock = true,
|
|
79
|
+
mongoPushEnabled = false,
|
|
80
|
+
onPushSessionToMongo,
|
|
81
|
+
pushingSessionId = null,
|
|
82
|
+
sessionDurationAlertThresholdMinutes,
|
|
83
|
+
}: {
|
|
84
|
+
sessions: SessionListEntry[];
|
|
85
|
+
lang: Lang;
|
|
86
|
+
/** Fuseau IANA pour la date affichée sur chaque session. */
|
|
87
|
+
displayTimeZone?: string;
|
|
88
|
+
/** Format 12 h / 24 h pour les horodatages début et fin de session. */
|
|
89
|
+
use24HourClock?: boolean;
|
|
90
|
+
liveSessionId: string | undefined;
|
|
91
|
+
/** When inspecting history, the selected id; when live, equals liveSessionId */
|
|
92
|
+
selectedSessionId: string | undefined;
|
|
93
|
+
t: DashboardStrings;
|
|
94
|
+
onSelectSession: (sessionId: string) => void;
|
|
95
|
+
/** Ouvre `?session=id` dans un nouvel onglet (URL dédiée, sans imposer la session aux autres onglets). */
|
|
96
|
+
onOpenSessionInNewTab?: (sessionId: string) => void;
|
|
97
|
+
/** Session live uniquement : enregistrer l’instantané dans l’historique sans « Nouvelle session ». */
|
|
98
|
+
onEndLiveSession?: () => void;
|
|
99
|
+
onArchiveSession?: (sessionId: string) => void;
|
|
100
|
+
onDeleteSession?: (sessionId: string) => void;
|
|
101
|
+
onOpenArchives?: () => void;
|
|
102
|
+
archivedCount?: number;
|
|
103
|
+
/** Mongo activé + connexion OK : affiche le bouton d’envoi par session. */
|
|
104
|
+
mongoPushEnabled?: boolean;
|
|
105
|
+
onPushSessionToMongo?: (sessionId: string) => void;
|
|
106
|
+
pushingSessionId?: string | null;
|
|
107
|
+
/** Minutes murales : durée affichée en alerte si ≥ seuil (même réglage que le panneau session). */
|
|
108
|
+
sessionDurationAlertThresholdMinutes?: number;
|
|
109
|
+
}) {
|
|
110
|
+
const sorted = sortSessions(sessions);
|
|
111
|
+
const archivesLabel =
|
|
112
|
+
archivedCount > 0 ? `${t.archivesModalTitle} (${archivedCount})` : t.archivesModalTitle;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<aside className="flex h-min min-w-0 max-w-full max-h-[calc(100vh-8rem)] flex-col rounded-xl border border-zinc-200 bg-white/90 shadow-sm dark:border-zinc-800 dark:bg-zinc-800/50 dark:shadow-none xl:z-0">
|
|
116
|
+
<div className="flex flex-wrap items-center justify-end gap-3 border-b border-zinc-200 px-5 py-3 dark:border-zinc-800">
|
|
117
|
+
{onOpenArchives ? (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
className="relative inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
|
121
|
+
onClick={onOpenArchives}
|
|
122
|
+
aria-label={archivesLabel}
|
|
123
|
+
title={archivesLabel}
|
|
124
|
+
>
|
|
125
|
+
<Archive size={18} strokeWidth={1.75} className="text-zinc-600 dark:text-zinc-300" aria-hidden />
|
|
126
|
+
{archivedCount > 0 ? (
|
|
127
|
+
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-violet-600 px-0.5 text-[0.6rem] font-semibold leading-none text-white">
|
|
128
|
+
{archivedCount > 99 ? "99+" : archivedCount}
|
|
129
|
+
</span>
|
|
130
|
+
) : null}
|
|
131
|
+
</button>
|
|
132
|
+
) : (
|
|
133
|
+
<span className="size-9 shrink-0" aria-hidden />
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-3 pr-4" aria-label={t.sessionsListAriaLabel}>
|
|
138
|
+
<ul className="space-y-1.5">
|
|
139
|
+
{sorted.map((sess) => {
|
|
140
|
+
const id = sess.sessionId;
|
|
141
|
+
const isLive = id === liveSessionId;
|
|
142
|
+
const isSelected = id === selectedSessionId;
|
|
143
|
+
const n = taskCount(sess);
|
|
144
|
+
const tz =
|
|
145
|
+
displayTimeZone.trim() && isValidIanaTimeZone(displayTimeZone.trim())
|
|
146
|
+
? displayTimeZone.trim()
|
|
147
|
+
: DEFAULT_DASHBOARD_TIME_ZONE;
|
|
148
|
+
const createdIso = (sess.createdAt?.trim() || sess.startAt?.trim() || sess.savedAt?.trim() || "").trim();
|
|
149
|
+
const createdLabel = formatIsoInstantShort(createdIso, lang, tz, use24HourClock);
|
|
150
|
+
const label = sess.sessionName?.trim() || (createdLabel ? `${id.slice(0, 8)} · ${createdLabel}` : id.slice(0, 8));
|
|
151
|
+
const startIso = (sess.startAt?.trim() || sess.savedAt?.trim() || "").trim();
|
|
152
|
+
const startLabel = formatIsoInstantShort(startIso, lang, tz, use24HourClock) ?? "—";
|
|
153
|
+
const hasEnd =
|
|
154
|
+
typeof sess.endAt === "string" && sess.endAt.trim() !== "";
|
|
155
|
+
const endLabel = hasEnd
|
|
156
|
+
? formatIsoInstantShort(sess.endAt!.trim(), lang, tz, use24HourClock) ?? "—"
|
|
157
|
+
: null;
|
|
158
|
+
|
|
159
|
+
const taskNoun = sessionTaskCountNoun(n, t, lang);
|
|
160
|
+
const wallMins = sessionWallClockMinutes(sess as LooseSession);
|
|
161
|
+
const durationLabel = wallMins > 0 ? formatDuration(wallMins) : "—";
|
|
162
|
+
const thresholdMin =
|
|
163
|
+
typeof sessionDurationAlertThresholdMinutes === "number" &&
|
|
164
|
+
Number.isFinite(sessionDurationAlertThresholdMinutes)
|
|
165
|
+
? Math.max(1, Math.round(sessionDurationAlertThresholdMinutes))
|
|
166
|
+
: null;
|
|
167
|
+
const durationAlert =
|
|
168
|
+
thresholdMin !== null && wallMins > 0 && wallMins >= thresholdMin;
|
|
169
|
+
const thresholdHours = thresholdMin !== null ? Math.round(thresholdMin / 60) : 0;
|
|
170
|
+
const durationTitle = durationAlert
|
|
171
|
+
? t.sessionListWallDurationAlertTooltip.replace("{hours}", String(thresholdHours))
|
|
172
|
+
: t.sessionListWallDurationTitle;
|
|
173
|
+
const mongoState = sessionMongoPushState(sess);
|
|
174
|
+
const mongoTitle =
|
|
175
|
+
mongoState === "synced"
|
|
176
|
+
? t.sessionMongoPushSyncedTitle
|
|
177
|
+
: mongoState === "dirty"
|
|
178
|
+
? t.sessionMongoPushDirtyTitle
|
|
179
|
+
: t.sessionMongoPushNeverTitle;
|
|
180
|
+
const mongoIconClass =
|
|
181
|
+
mongoState === "synced"
|
|
182
|
+
? "text-emerald-400/95"
|
|
183
|
+
: mongoState === "dirty"
|
|
184
|
+
? "text-amber-400/95"
|
|
185
|
+
: "text-zinc-500";
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<li key={id} id={`kronosys-session-${id}`} className="scroll-mt-20">
|
|
189
|
+
<div
|
|
190
|
+
className={`grid grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto] gap-x-1.5 gap-y-1 rounded-lg px-3.5 py-3 transition-colors ${
|
|
191
|
+
isSelected
|
|
192
|
+
? "bg-violet-500/15 ring-1 ring-violet-500/45 dark:bg-violet-600/20 dark:ring-violet-500/50"
|
|
193
|
+
: "hover:bg-zinc-100/90 dark:hover:bg-zinc-800/80"
|
|
194
|
+
}`}
|
|
195
|
+
>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={() => onSelectSession(id)}
|
|
199
|
+
className="group col-start-1 row-start-1 row-span-2 flex min-w-0 flex-col gap-0.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 rounded-md -mx-1 px-1 -my-0.5 py-0.5"
|
|
200
|
+
>
|
|
201
|
+
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-800 group-hover:text-zinc-950 dark:text-zinc-100 dark:group-hover:text-white">
|
|
202
|
+
{isLive && (
|
|
203
|
+
<Circle
|
|
204
|
+
className="shrink-0 fill-emerald-400 text-emerald-400"
|
|
205
|
+
size={10}
|
|
206
|
+
aria-hidden
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
<span className="truncate">{label}</span>
|
|
210
|
+
{isLive && (
|
|
211
|
+
<span className="shrink-0 rounded bg-emerald-100 px-1.5 py-0 text-[0.6rem] font-bold uppercase tracking-wide text-emerald-900 ring-1 ring-emerald-600/20 dark:bg-emerald-900/50 dark:text-emerald-300 dark:ring-0">
|
|
212
|
+
{t.sessionLiveBadge}
|
|
213
|
+
</span>
|
|
214
|
+
)}
|
|
215
|
+
</span>
|
|
216
|
+
<span className="flex min-w-0 flex-col gap-0.5 text-[0.7rem] leading-snug text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-400">
|
|
217
|
+
<span className="min-w-0 break-words">
|
|
218
|
+
<span className="text-zinc-600 dark:text-zinc-500">{t.sessionListStartedPrefix}</span>{" "}
|
|
219
|
+
{startLabel}
|
|
220
|
+
</span>
|
|
221
|
+
{endLabel !== null ? (
|
|
222
|
+
<span className="min-w-0 break-words">
|
|
223
|
+
<span className="text-zinc-600 dark:text-zinc-500">{t.sessionListEndedPrefix}</span>{" "}
|
|
224
|
+
{endLabel}
|
|
225
|
+
</span>
|
|
226
|
+
) : null}
|
|
227
|
+
<span className="min-w-0 truncate">
|
|
228
|
+
<span>
|
|
229
|
+
{n} {taskNoun}
|
|
230
|
+
</span>
|
|
231
|
+
<span className="text-zinc-400 dark:text-zinc-600"> · </span>
|
|
232
|
+
<span
|
|
233
|
+
className={`inline tabular-nums ${
|
|
234
|
+
durationAlert ? "kronosys-session-duration-alert font-semibold" : ""
|
|
235
|
+
}`}
|
|
236
|
+
title={durationTitle}
|
|
237
|
+
>
|
|
238
|
+
{durationLabel}
|
|
239
|
+
</span>
|
|
240
|
+
</span>
|
|
241
|
+
</span>
|
|
242
|
+
</button>
|
|
243
|
+
<div className="col-start-2 row-start-2 flex shrink-0 items-center gap-0.5 self-center">
|
|
244
|
+
{mongoPushEnabled && onPushSessionToMongo ? (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className={`${sessionRowActionBase} ${mongoIconClass} hover:text-sky-600 disabled:opacity-40 dark:hover:text-sky-300`}
|
|
248
|
+
title={mongoTitle}
|
|
249
|
+
aria-label={t.sessionMongoPushAriaLabel}
|
|
250
|
+
disabled={pushingSessionId === id}
|
|
251
|
+
onClick={(e) => {
|
|
252
|
+
e.stopPropagation();
|
|
253
|
+
onPushSessionToMongo(id);
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
{pushingSessionId === id ? (
|
|
257
|
+
<Loader2
|
|
258
|
+
className="animate-spin text-zinc-500 dark:text-zinc-400"
|
|
259
|
+
size={sessionRowActionIconSize}
|
|
260
|
+
aria-label={t.sessionMongoPushBusy}
|
|
261
|
+
/>
|
|
262
|
+
) : (
|
|
263
|
+
<UploadCloud size={sessionRowActionIconSize} aria-hidden />
|
|
264
|
+
)}
|
|
265
|
+
</button>
|
|
266
|
+
) : null}
|
|
267
|
+
{isLive && onEndLiveSession ? (
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
className={`${sessionRowActionBase} text-zinc-500 hover:text-rose-600 dark:hover:text-rose-300`}
|
|
271
|
+
title={t.sessionEndLiveTitle}
|
|
272
|
+
aria-label={t.sessionEndLiveAria}
|
|
273
|
+
onClick={() => onEndLiveSession()}
|
|
274
|
+
>
|
|
275
|
+
<Square size={sessionRowActionIconSize} aria-hidden />
|
|
276
|
+
</button>
|
|
277
|
+
) : null}
|
|
278
|
+
{onOpenSessionInNewTab ? (
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
className={`${sessionRowActionBase} text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200`}
|
|
282
|
+
title={t.openSessionInNewTab}
|
|
283
|
+
aria-label={t.openSessionInNewTab}
|
|
284
|
+
onClick={() => onOpenSessionInNewTab(id)}
|
|
285
|
+
>
|
|
286
|
+
<ExternalLink size={sessionRowActionIconSize} aria-hidden />
|
|
287
|
+
</button>
|
|
288
|
+
) : null}
|
|
289
|
+
{onArchiveSession && !isLive ? (
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
className={`${sessionRowActionBase} text-zinc-500 hover:text-amber-700 dark:hover:text-amber-200/90`}
|
|
293
|
+
title={t.sessionArchiveTitle}
|
|
294
|
+
aria-label={t.sessionArchiveBtn}
|
|
295
|
+
onClick={() => onArchiveSession(id)}
|
|
296
|
+
>
|
|
297
|
+
<Archive size={sessionRowActionIconSize} aria-hidden />
|
|
298
|
+
</button>
|
|
299
|
+
) : null}
|
|
300
|
+
{onDeleteSession && !isLive ? (
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
className={`${sessionRowActionBase} text-zinc-500 hover:text-red-600 dark:hover:text-red-300`}
|
|
304
|
+
title={t.sessionDeleteTitle}
|
|
305
|
+
aria-label={t.sessionDeleteBtn}
|
|
306
|
+
onClick={() => onDeleteSession(id)}
|
|
307
|
+
>
|
|
308
|
+
<Trash2 size={sessionRowActionIconSize} aria-hidden />
|
|
309
|
+
</button>
|
|
310
|
+
) : null}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</li>
|
|
314
|
+
);
|
|
315
|
+
})}
|
|
316
|
+
</ul>
|
|
317
|
+
</nav>
|
|
318
|
+
</aside>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
4
|
+
import { DashboardCollapsibleSection } from "./DashboardCollapsibleSection";
|
|
5
|
+
import { InlineMetricHelpTrigger } from "./InlineMetricHelpTrigger";
|
|
6
|
+
|
|
7
|
+
export type SessionLocMetricsShape = {
|
|
8
|
+
linesWrittenTotal?: number;
|
|
9
|
+
linesWrittenHuman?: number;
|
|
10
|
+
linesWrittenAi?: number;
|
|
11
|
+
locByLanguage?: Array<[string, number]>;
|
|
12
|
+
codingSignalsByLanguage?: Array<[string, number]>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function SessionLocMetricsSection({
|
|
16
|
+
session,
|
|
17
|
+
t,
|
|
18
|
+
}: {
|
|
19
|
+
session: SessionLocMetricsShape;
|
|
20
|
+
t: DashboardStrings;
|
|
21
|
+
}) {
|
|
22
|
+
const total = session.linesWrittenTotal ?? 0;
|
|
23
|
+
const human = session.linesWrittenHuman ?? 0;
|
|
24
|
+
const ai = session.linesWrittenAi ?? 0;
|
|
25
|
+
const locRows = [...(session.locByLanguage ?? [])].sort((a, b) => b[1] - a[1]);
|
|
26
|
+
const sigRows = [...(session.codingSignalsByLanguage ?? [])].sort((a, b) => b[1] - a[1]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<DashboardCollapsibleSection
|
|
30
|
+
sectionAriaLabel={t.statsLocSectionTitle}
|
|
31
|
+
title={
|
|
32
|
+
<span className="text-sm font-semibold text-zinc-800 dark:text-zinc-200">{t.statsLocSectionTitle}</span>
|
|
33
|
+
}
|
|
34
|
+
>
|
|
35
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
36
|
+
<div>
|
|
37
|
+
<div className="flex min-h-5 items-center gap-0.5">
|
|
38
|
+
<span className="text-xs uppercase text-zinc-500 dark:text-zinc-500">{t.statsLinesWrittenTotal}</span>
|
|
39
|
+
<InlineMetricHelpTrigger
|
|
40
|
+
ariaLabel={t.statsMetricLinesWrittenHelpAria}
|
|
41
|
+
body={t.statsMetricLinesWrittenHelpBody}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">{total}</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div>
|
|
47
|
+
<div className="flex min-h-5 items-center gap-0.5">
|
|
48
|
+
<span className="text-xs uppercase text-zinc-500 dark:text-zinc-500">{t.statsLinesWrittenHuman}</span>
|
|
49
|
+
<InlineMetricHelpTrigger
|
|
50
|
+
ariaLabel={t.statsMetricLinesWrittenHelpAria}
|
|
51
|
+
body={t.statsMetricLinesWrittenHelpBody}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="mt-1 text-2xl font-semibold tabular-nums text-emerald-400/90">{human}</div>
|
|
55
|
+
</div>
|
|
56
|
+
<div>
|
|
57
|
+
<div className="flex min-h-5 items-center gap-0.5">
|
|
58
|
+
<span className="text-xs uppercase text-zinc-500 dark:text-zinc-500">{t.statsLinesWrittenAi}</span>
|
|
59
|
+
<InlineMetricHelpTrigger
|
|
60
|
+
ariaLabel={t.statsMetricLinesWrittenHelpAria}
|
|
61
|
+
body={t.statsMetricLinesWrittenHelpBody}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="mt-1 text-2xl font-semibold tabular-nums text-violet-400/90">{ai}</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
|
69
|
+
<div>
|
|
70
|
+
<div className="mb-2 flex min-h-5 items-center gap-0.5">
|
|
71
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
|
|
72
|
+
{t.statsLocByLanguageHeading}
|
|
73
|
+
</span>
|
|
74
|
+
<InlineMetricHelpTrigger
|
|
75
|
+
ariaLabel={t.statsMetricLocByLanguageHelpAria}
|
|
76
|
+
body={t.statsMetricLocByLanguageHelpBody}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
{locRows.length === 0 ? (
|
|
80
|
+
<p className="text-sm text-zinc-500">{t.noData}</p>
|
|
81
|
+
) : (
|
|
82
|
+
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800/90 dark:border-zinc-800/80">
|
|
83
|
+
{locRows.map(([lang, n]) => (
|
|
84
|
+
<li
|
|
85
|
+
key={lang}
|
|
86
|
+
className="flex items-center justify-between gap-3 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200"
|
|
87
|
+
>
|
|
88
|
+
<span className="min-w-0 truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
|
89
|
+
{lang}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="shrink-0 tabular-nums text-zinc-900 dark:text-zinc-100">{n}</span>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<div className="mb-2 flex min-h-5 items-center gap-0.5">
|
|
99
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
|
|
100
|
+
{t.statsCodingSignalsHeading}
|
|
101
|
+
</span>
|
|
102
|
+
<InlineMetricHelpTrigger
|
|
103
|
+
ariaLabel={t.statsMetricCodingSignalsHelpAria}
|
|
104
|
+
body={t.statsMetricCodingSignalsHelpBody}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
{sigRows.length === 0 ? (
|
|
108
|
+
<p className="text-sm text-zinc-500">{t.noData}</p>
|
|
109
|
+
) : (
|
|
110
|
+
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800/90 dark:border-zinc-800/80">
|
|
111
|
+
{sigRows.map(([lang, n]) => (
|
|
112
|
+
<li
|
|
113
|
+
key={lang}
|
|
114
|
+
className="flex items-center justify-between gap-3 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200"
|
|
115
|
+
>
|
|
116
|
+
<span className="min-w-0 truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
|
117
|
+
{lang}
|
|
118
|
+
</span>
|
|
119
|
+
<span className="shrink-0 tabular-nums text-zinc-900 dark:text-zinc-100">{n}</span>
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</DashboardCollapsibleSection>
|
|
127
|
+
);
|
|
128
|
+
}
|