Skip to content

Commit e37efcb

Browse files
feat: implement Time Machine, modern Tab Bar v4, and UI harmonization
1 parent 6bde628 commit e37efcb

11 files changed

Lines changed: 196 additions & 76 deletions

File tree

web/src/components/home/InteractiveTimeline.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ export const InteractiveTimeline: React.FC<InteractiveTimelineProps> = ({ queues
3939
setIsDragging(false);
4040
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
4141

42-
// Auto-return after 3 seconds of idle time
42+
// Auto-return after 5 seconds of idle time
4343
setTimeout(() => {
4444
setSelectedSlot(null);
4545
if (onScrub) onScrub(null);
46-
}, 3000);
46+
}, 5000);
4747
};
4848

4949
const updatePosition = (clientX: number) => {
@@ -161,13 +161,20 @@ export const InteractiveTimeline: React.FC<InteractiveTimelineProps> = ({ queues
161161
>
162162
{Array.from({ length: 48 }).map((_, i) => {
163163
const isAvailable = queuesStr[Math.floor(i / 2)] === '1';
164+
const realSlot = getCurrentSlot();
165+
const isPast = i < realSlot;
166+
167+
// Muted colors for history (fixed to real time)
168+
const availableColor = isPast ? '#FFB380' : '#FF7A00';
169+
const unavailableColor = isPast ? 'rgba(232, 232, 237, 0.4)' : 'transparent';
170+
164171
return (
165172
<div
166173
key={i}
167174
className={clsx("track__segment", isAvailable ? "track__segment--available" : "track__segment--unavailable")}
168175
style={{
169176
flex: 1,
170-
background: isAvailable ? '#FF7A00' : 'transparent',
177+
background: isAvailable ? availableColor : unavailableColor,
171178
height: '100%'
172179
}}
173180
/>

web/src/components/home/SubqueueSelector.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ export const SubqueueSelector: React.FC<SubqueueSelectorProps> = ({
128128
style={{
129129
flex: `0 0 ${cardWidth}px`,
130130
height: `${cardWidth}px`, // Perfectly square
131-
borderRadius: '4px',
131+
borderRadius: '8px',
132132
display: 'flex',
133133
flexDirection: 'column',
134134
alignItems: 'center',
@@ -144,7 +144,7 @@ export const SubqueueSelector: React.FC<SubqueueSelectorProps> = ({
144144
}}
145145
>
146146
<span style={{
147-
fontSize: isActive ? '22px' : '18px',
147+
fontSize: isActive ? '22px' : '20px',
148148
fontWeight: isActive ? 800 : 700,
149149
color: isActive ? '#111' : '#6B7280',
150150
lineHeight: 1
@@ -153,17 +153,19 @@ export const SubqueueSelector: React.FC<SubqueueSelectorProps> = ({
153153
</span>
154154

155155
{/* Preserve space for the label even if inactive to maintain geometry */}
156-
<span style={{
157-
fontSize: '8px',
158-
fontWeight: 600,
159-
color: '#888',
160-
marginTop: '2px',
161-
letterSpacing: '0.2px',
162-
opacity: isActive ? 1 : 0,
163-
transition: 'opacity 0.2s'
164-
}}>
165-
підчерга
166-
</span>
156+
{isActive && (
157+
<span style={{
158+
fontSize: '8px',
159+
fontWeight: 600,
160+
color: '#888',
161+
marginTop: '2px',
162+
letterSpacing: '0.2px',
163+
opacity: 1,
164+
transition: 'opacity 0.2s'
165+
}}>
166+
підчерга
167+
</span>
168+
)}
167169
</div>
168170
);
169171
})}

web/src/components/navigation/BottomNav.tsx

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,49 +7,59 @@ interface NavItemProps {
77
to: string;
88
label: string;
99
active: boolean;
10-
icon: React.ReactNode;
10+
icon: React.ReactElement;
1111
}
1212

13-
const NavItem: React.FC<NavItemProps> = ({ to, label, active, icon }) => (
14-
<Link to={to} className={clsx("nav-item", active && "active")}>
15-
<span className="icon">
16-
{icon}
17-
</span>
18-
<span className="nav-text">{label}</span>
19-
</Link>
20-
);
13+
const NavItem: React.FC<NavItemProps> = ({ to, label, active, icon }) => {
14+
// Clone icon to apply dynamic styling based on active state
15+
const styledIcon = React.cloneElement(icon as React.ReactElement<any>, {
16+
size: 20,
17+
strokeWidth: active ? 2.5 : 2,
18+
fill: active ? 'currentColor' : 'none',
19+
className: clsx("nav-icon", active && "active")
20+
});
21+
22+
return (
23+
<Link to={to} className={clsx("nav-item-v4", active && "active")}>
24+
<div className="pill-bg">
25+
{styledIcon}
26+
{active && <span className="nav-text-v4">{label}</span>}
27+
</div>
28+
</Link>
29+
);
30+
};
2131

2232
export const BottomNav: React.FC = () => {
2333
const location = useLocation();
2434

2535
return (
26-
<nav className="glass-nav">
36+
<nav className="floating-nav-v4">
2737
<NavItem
2838
to="/"
29-
label="Home"
39+
label="Головна"
3040
active={location.pathname === '/'}
31-
icon={<Home size={20} />}
41+
icon={<Home />}
3242
/>
3343

3444
<NavItem
3545
to="/archive"
36-
label="Archive"
46+
label="Архів"
3747
active={location.pathname === '/archive'}
38-
icon={<Calendar size={20} />}
48+
icon={<Calendar />}
3949
/>
4050

4151
<NavItem
4252
to="/apps"
4353
label="Apps"
4454
active={location.pathname === '/apps'}
45-
icon={<LayoutGrid size={20} />}
55+
icon={<LayoutGrid />}
4656
/>
4757

4858
<NavItem
4959
to="/cabinet"
50-
label="Profile"
60+
label="Кабінет"
5161
active={location.pathname === '/cabinet'}
52-
icon={<User size={20} />}
62+
icon={<User />}
5363
/>
5464
</nav>
5565
);

web/src/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
--radius-pill: 20px;
3131
--gap-main: 6px;
3232
--gap-small: 8px;
33+
--page-top-gap: 20px;
3334
--color-orange: #EE7221;
3435
--color-dark: #374151;
3536
--color-bg-gray: #f4f4f5;

web/src/pages/Cabinet/Cabinet.module.css

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
display: flex;
1717
flex-direction: column;
1818
gap: 6px;
19-
padding: 12px 16px 100px 16px;
20-
max-width: 480px;
19+
padding: 0 16px 100px 16px;
20+
max-width: 450px;
2121
margin: 0 auto;
2222
width: 100%;
2323
box-sizing: border-box;
@@ -40,9 +40,9 @@
4040
text-transform: uppercase;
4141
letter-spacing: 0.05em;
4242
color: #71717a;
43-
padding: 0 16px; /* Align with internal card content (16px from card edge) */
44-
margin-top: 12px;
45-
margin-bottom: 2px;
43+
padding: 0 12px; /* Align with internal card content (12px from card edge) */
44+
margin-top: 8px;
45+
margin-bottom: 0px;
4646
}
4747

4848
/* ============================================
@@ -63,7 +63,7 @@
6363
display: flex;
6464
align-items: center;
6565
gap: 16px;
66-
padding: 16px; /* Standard 16px padding */
66+
padding: 12px; /* Standard 12px padding */
6767
}
6868

6969
.avatarWrap {
@@ -122,10 +122,10 @@
122122
NOTIFICATION SLOTS
123123
============================================ */
124124
.slotCard {
125-
padding: 16px; /* Unified internal padding */
125+
padding: 12px; /* Unified internal padding */
126126
display: flex;
127127
flex-direction: column;
128-
gap: 10px;
128+
gap: 6px;
129129
}
130130

131131
/* This wrapper creates the 'Phantom Gap' to align text with the icons below */
@@ -186,10 +186,10 @@
186186
display: flex;
187187
justify-content: space-between;
188188
align-items: center;
189-
padding: 16px; /* Unified 16px padding */
189+
padding: 12px; /* Unified 12px padding */
190190
cursor: pointer;
191191
position: relative;
192-
min-height: 64px;
192+
min-height: 52px;
193193
box-sizing: border-box;
194194
width: 100%;
195195
}

web/src/pages/Cabinet/Cabinet.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { NotificationEditorSheet } from './components/NotificationEditorSheet';
99
import { AuthPromptSheet } from './components/AuthPromptSheet';
1010
import { AboutSheet } from './components/AboutSheet';
1111
import type { Slot } from '@/schemas/user';
12+
import { clsx } from 'clsx';
1213
import styles from './Cabinet.module.css';
1314

1415
export const Cabinet: React.FC = () => {
@@ -84,7 +85,7 @@ export const Cabinet: React.FC = () => {
8485
};
8586

8687
return (
87-
<div className={styles.cabinetRoot}>
88+
<div className={clsx(styles.cabinetRoot, 'page-cabinet')}>
8889
<ProfileCard user={profile} onClick={() => setAuthSheetOpen(true)} />
8990

9091
<NotificationSlots

web/src/pages/Home.tsx

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useEffect, useState } from 'react';
2+
import { clsx } from 'clsx';
23
import { useStore } from '@/store/useStore';
34
import { getStatusInfo } from '@/services/scheduleService';
45
import { HeroCard } from '@/components/home/HeroCard';
@@ -28,19 +29,29 @@ export const Home: React.FC = () => {
2829
return () => clearInterval(timer);
2930
}, []);
3031

31-
// REAL TIME STATUS (Always facts for now)
32-
const currentHour = realTime.getHours();
33-
const isOn = currentQueuesStr[currentHour] === '1';
34-
35-
let nextChangeHour = 24;
36-
for (let i = currentHour + 1; i < 24; i++) {
37-
if (currentQueuesStr[i] !== (isOn ? '1' : '0')) {
38-
nextChangeHour = i;
39-
break;
32+
// CENTRALIZED DISPLAY CONTEXT (The 'Time Machine' Hub)
33+
const displayContext = React.useMemo(() => {
34+
// Determine the reference time (Real or Virtual)
35+
let referenceDate = new Date(realTime);
36+
37+
if (scrubSlot !== null) {
38+
// Set to virtual time (slot 0 = 00:00, slot 1 = 00:30, etc.)
39+
const vHour = Math.floor(scrubSlot / 2);
40+
const vMin = (scrubSlot % 2) * 30;
41+
referenceDate.setHours(vHour, vMin, 0, 0);
4042
}
41-
}
4243

43-
const { h: timeH, m: timeM } = getStatusInfo(currentQueuesStr);
44+
const info = getStatusInfo(currentQueuesStr, referenceDate);
45+
46+
return {
47+
referenceDate,
48+
isVirtual: scrubSlot !== null,
49+
isOn: info.isCurrentlyOn,
50+
timeH: info.h,
51+
timeM: info.m,
52+
nextChangeHour: info.nextChangeHour
53+
};
54+
}, [realTime, scrubSlot, currentQueuesStr]);
4455

4556
// VIRTUAL POINTER PERCENT
4657
// Use SLOTS_COUNT - 1 (47) to perfectly align 100% with the right edge
@@ -49,21 +60,21 @@ export const Home: React.FC = () => {
4960
: ((realTime.getHours() * 60 + realTime.getMinutes()) / 1440) * 100;
5061

5162
return (
52-
<div className="page-home">
63+
<div className={clsx("page-home", !displayContext.isOn && "status-off")}>
5364
<section id="home-section" className="animate-in fade-in duration-700">
54-
<div style={{ height: '16px' }} />
55-
56-
5765
<HeroCard
58-
isOn={isOn}
59-
timeH={timeH}
60-
timeM={timeM}
61-
nextChangeHour={nextChangeHour}
66+
isOn={displayContext.isOn}
67+
timeH={displayContext.timeH}
68+
timeM={displayContext.timeM}
69+
nextChangeHour={displayContext.nextChangeHour}
6270
queuesStr={currentQueuesStr}
6371
currentTimePercent={pointerPercent}
6472
/>
6573

66-
<DashboardBar isOn={isOn} realTime={realTime} />
74+
<DashboardBar
75+
isOn={displayContext.isOn}
76+
realTime={displayContext.referenceDate}
77+
/>
6778

6879
<InteractiveTimeline
6980
queuesStr={currentQueuesStr}
@@ -74,9 +85,6 @@ export const Home: React.FC = () => {
7485
selectedGroup={selectedGroup}
7586
onSelect={setSelectedGroup}
7687
/>
77-
78-
{/* Spacer for bottom nav */}
79-
<div style={{ height: '100px' }} />
8088
</section>
8189
</div>
8290
);

web/src/services/scheduleService.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ export const convertToIntervals = (scheduleString: string): TimeInterval[] => {
3535
};
3636

3737
/**
38-
* Calculates current status and time until next change
38+
* Calculates status and time until next change relative to a reference date.
39+
* Useful for 'Time Machine' scrubbing.
3940
*/
40-
export const getStatusInfo = (scheduleString: string) => {
41-
const now = new Date();
42-
const currentHour = now.getHours();
41+
export const getStatusInfo = (scheduleString: string, referenceDate?: Date) => {
42+
const baseTime = referenceDate || new Date();
43+
const currentHour = baseTime.getHours();
4344
const isCurrentlyOn = scheduleString[currentHour] === '1';
4445

4546
let nextChangeHour = 24;
@@ -50,9 +51,11 @@ export const getStatusInfo = (scheduleString: string) => {
5051
}
5152
}
5253

53-
const nextTime = new Date();
54+
const nextTime = new Date(baseTime);
5455
nextTime.setHours(nextChangeHour, 0, 0, 0);
55-
const diffMs = nextTime.getTime() - now.getTime();
56+
57+
// Calculate difference in minutes from the reference point
58+
const diffMs = nextTime.getTime() - baseTime.getTime();
5659
const minutesRemaining = Math.max(0, Math.floor(diffMs / 60000));
5760

5861
return {

0 commit comments

Comments
 (0)