Skip to content

Commit ae78496

Browse files
feat: implement YearStrip component with year navigation and drag functionality
1 parent f5229e2 commit ae78496

7 files changed

Lines changed: 302 additions & 7 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<nav class="year-strip">
2+
<div class="container">
3+
<div class="strip-inner">
4+
<button class="strip-arrow left" (click)="scrollYears(-1)" aria-label="Scroll left">
5+
6+
</button>
7+
<div
8+
class="strip-track"
9+
#yearTrack
10+
(mousedown)="onDragStart($event)"
11+
(touchstart)="onDragStart($event)"
12+
>
13+
@for (year of years(); track year.gregorianYear) {
14+
<a
15+
class="year-box"
16+
[class.active]="year.gregorianYear === activeYear()"
17+
[routerLink]="year.firstMonthRoute"
18+
>
19+
<span class="year-greg">{{ year.gregorianYear }}</span>
20+
<span class="year-hijri">{{ year.islamicYearLabel }}</span>
21+
</a>
22+
}
23+
</div>
24+
<button class="strip-arrow right" (click)="scrollYears(1)" aria-label="Scroll right">
25+
26+
</button>
27+
</div>
28+
</div>
29+
</nav>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
.year-strip {
2+
background: #0f1923;
3+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
4+
}
5+
6+
.strip-inner {
7+
display: flex;
8+
align-items: stretch;
9+
}
10+
11+
.strip-arrow {
12+
flex-shrink: 0;
13+
width: 36px;
14+
display: flex;
15+
align-items: center;
16+
justify-content: center;
17+
background: rgba(255, 255, 255, 0.03);
18+
border: none;
19+
color: #90a4ae;
20+
font-size: 1.6rem;
21+
cursor: pointer;
22+
transition: background 0.15s;
23+
&:hover {
24+
background: rgba(255, 255, 255, 0.08);
25+
color: #fff;
26+
}
27+
}
28+
29+
.strip-track {
30+
flex: 1;
31+
display: flex;
32+
gap: 6px;
33+
overflow-x: auto;
34+
scroll-behavior: smooth;
35+
padding: 8px 6px;
36+
cursor: grab;
37+
-ms-overflow-style: none;
38+
scrollbar-width: none;
39+
&::-webkit-scrollbar {
40+
display: none;
41+
}
42+
&:active {
43+
cursor: grabbing;
44+
}
45+
}
46+
47+
.year-box {
48+
flex-shrink: 0;
49+
display: flex;
50+
flex-direction: column;
51+
align-items: center;
52+
gap: 1px;
53+
padding: 6px 18px;
54+
border-radius: 8px;
55+
text-decoration: none;
56+
color: #b0bec5;
57+
background: rgba(255, 255, 255, 0.03);
58+
border: 1px solid transparent;
59+
transition:
60+
background 0.15s,
61+
border-color 0.15s;
62+
cursor: pointer;
63+
user-select: none;
64+
65+
&:hover {
66+
background: rgba(255, 255, 255, 0.07);
67+
}
68+
69+
&.active {
70+
background: rgba(25, 118, 210, 0.18);
71+
border-color: #42a5f5;
72+
}
73+
}
74+
75+
.year-greg {
76+
font-size: 0.9rem;
77+
font-weight: 700;
78+
color: #fff;
79+
}
80+
81+
.year-hijri {
82+
font-size: 0.65rem;
83+
color: #78909c;
84+
}
85+
86+
@media (max-width: 800px) {
87+
.year-box {
88+
padding: 5px 12px;
89+
}
90+
91+
.year-greg {
92+
font-size: 0.82rem;
93+
}
94+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Component, input, viewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
2+
import { RouterModule } from '@angular/router';
3+
4+
export interface YearEntry {
5+
gregorianYear: number;
6+
islamicYearLabel: string;
7+
firstMonthRoute: string;
8+
}
9+
10+
@Component({
11+
selector: 'app-year-strip',
12+
imports: [RouterModule],
13+
templateUrl: './year-strip.component.html',
14+
styleUrl: './year-strip.component.scss',
15+
})
16+
export class YearStripComponent implements AfterViewInit, OnDestroy {
17+
readonly years = input.required<YearEntry[]>();
18+
readonly activeYear = input.required<number>();
19+
20+
private readonly yearTrackRef = viewChild<ElementRef<HTMLDivElement>>('yearTrack');
21+
22+
// Drag state
23+
private dragging = false;
24+
private dragStartX = 0;
25+
private dragScrollLeft = 0;
26+
private hasDragged = false;
27+
28+
ngAfterViewInit() {
29+
setTimeout(() => {
30+
const track = this.yearTrackRef()?.nativeElement;
31+
const active = track?.querySelector('.active') as HTMLElement | null;
32+
if (active && track) {
33+
active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
34+
}
35+
}, 100);
36+
37+
document.addEventListener('mousemove', this.onDragMove);
38+
document.addEventListener('mouseup', this.onDragEnd);
39+
document.addEventListener('touchmove', this.onDragMove, { passive: false });
40+
document.addEventListener('touchend', this.onDragEnd);
41+
}
42+
43+
ngOnDestroy() {
44+
document.removeEventListener('mousemove', this.onDragMove);
45+
document.removeEventListener('mouseup', this.onDragEnd);
46+
document.removeEventListener('touchmove', this.onDragMove);
47+
document.removeEventListener('touchend', this.onDragEnd);
48+
}
49+
50+
scrollYears(dir: number) {
51+
const track = this.yearTrackRef()?.nativeElement;
52+
if (!track) return;
53+
track.scrollBy({ left: dir * 200, behavior: 'smooth' });
54+
}
55+
56+
onDragStart = (e: MouseEvent | TouchEvent) => {
57+
const track = this.yearTrackRef()?.nativeElement;
58+
if (!track) return;
59+
this.dragging = true;
60+
this.hasDragged = false;
61+
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
62+
this.dragStartX = clientX;
63+
this.dragScrollLeft = track.scrollLeft;
64+
};
65+
66+
private onDragMove = (e: MouseEvent | TouchEvent) => {
67+
if (!this.dragging) return;
68+
const track = this.yearTrackRef()?.nativeElement;
69+
if (!track) return;
70+
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
71+
const dx = clientX - this.dragStartX;
72+
if (Math.abs(dx) > 4) this.hasDragged = true;
73+
track.scrollLeft = this.dragScrollLeft - dx;
74+
if (this.hasDragged) e.preventDefault();
75+
};
76+
77+
private onDragEnd = () => {
78+
this.dragging = false;
79+
};
80+
}

src/app/pages/home.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<div class="shell">
22
<app-topbar />
33
<app-header-message />
4+
<app-year-strip [years]="yearEntries()" [activeYear]="activeGregorianYear()" />
45
<app-month-strip [months]="islamicMonths()" />
56

67
<!-- Main content area -->

src/app/pages/home.component.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { Component, signal, inject, OnInit } from '@angular/core';
2-
import { RouterModule, Router, ActivatedRoute } from '@angular/router';
1+
import { Component, signal, inject, OnInit, OnDestroy } from '@angular/core';
2+
import { RouterModule, Router, ActivatedRoute, NavigationEnd } from '@angular/router';
3+
import { filter, Subscription } from 'rxjs';
34
import { IslamicMonthService, IslamicMonthEntry } from '../services/islamic-month.service';
45
import { LocationService } from '../services/location.service';
56
import { LocationDialogService } from '../services/location-dialog.service';
67
import { LocationDialogComponent } from '../components/location-dialog.component';
78
import { TopbarComponent } from '../components/topbar.component';
89
import { HeaderMessageComponent } from '../components/header-message.component';
910
import { MonthStripComponent } from '../components/month-strip.component';
11+
import { YearStripComponent, YearEntry } from '../components/year-strip.component';
1012
import { FooterComponent } from '../components/footer.component';
1113

1214
@Component({
@@ -17,24 +19,39 @@ import { FooterComponent } from '../components/footer.component';
1719
TopbarComponent,
1820
HeaderMessageComponent,
1921
MonthStripComponent,
22+
YearStripComponent,
2023
FooterComponent,
2124
],
2225
templateUrl: './home.component.html',
2326
styleUrl: './home.component.scss',
2427
})
25-
export class HomeComponent implements OnInit {
28+
export class HomeComponent implements OnInit, OnDestroy {
2629
private readonly moonService = inject(IslamicMonthService);
2730
private readonly router = inject(Router);
2831
private readonly route = inject(ActivatedRoute);
2932
private readonly locationService = inject(LocationService);
3033
readonly locationDialogService = inject(LocationDialogService);
3134
readonly islamicMonths = signal<IslamicMonthEntry[]>([]);
35+
readonly yearEntries = signal<YearEntry[]>([]);
36+
readonly activeGregorianYear = signal(new Date().getFullYear());
37+
38+
/** Cache of months per Gregorian year to avoid re-computation */
39+
private yearMonthsCache = new Map<number, IslamicMonthEntry[]>();
40+
private routerSub!: Subscription;
3241

3342
constructor() {
34-
this.islamicMonths.set(this.moonService.getUpcomingIslamicMonths(new Date(), 12));
43+
this.initYears();
3544
}
3645

3746
ngOnInit() {
47+
// Detect active year from initial route
48+
this.syncActiveYearFromRoute();
49+
50+
// Listen to route changes to update active year
51+
this.routerSub = this.router.events
52+
.pipe(filter((e) => e instanceof NavigationEnd))
53+
.subscribe(() => this.syncActiveYearFromRoute());
54+
3855
if (!this.route.firstChild) {
3956
const defaultRoute = this.moonService.getNearestMonthRoute(this.islamicMonths());
4057
this.router.navigateByUrl(defaultRoute, { replaceUrl: true });
@@ -46,6 +63,63 @@ export class HomeComponent implements OnInit {
4663
}
4764
}
4865

66+
ngOnDestroy() {
67+
this.routerSub?.unsubscribe();
68+
}
69+
70+
private initYears() {
71+
const currentYear = new Date().getFullYear();
72+
const years: YearEntry[] = [];
73+
74+
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
75+
const months = this.getMonthsForYear(y);
76+
if (months.length === 0) continue;
77+
const uniqueIslamic = [...new Set(months.map((m) => m.year))].sort();
78+
const islamicYearLabel =
79+
uniqueIslamic.length > 1
80+
? `${uniqueIslamic[0]}${uniqueIslamic[uniqueIslamic.length - 1]} AH`
81+
: `${uniqueIslamic[0]} AH`;
82+
years.push({
83+
gregorianYear: y,
84+
islamicYearLabel,
85+
firstMonthRoute: `/${months[0].year}AH/${months[0].routeSlug}`,
86+
});
87+
}
88+
89+
this.yearEntries.set(years);
90+
// Default: load months for current Gregorian year
91+
this.islamicMonths.set(this.getMonthsForYear(currentYear));
92+
}
93+
94+
private getMonthsForYear(year: number): IslamicMonthEntry[] {
95+
if (!this.yearMonthsCache.has(year)) {
96+
this.yearMonthsCache.set(year, this.moonService.getIslamicMonthsForGregorianYear(year));
97+
}
98+
return this.yearMonthsCache.get(year)!;
99+
}
100+
101+
private syncActiveYearFromRoute() {
102+
const child = this.route.firstChild;
103+
if (!child) return;
104+
const yearStr = child.snapshot.params['year'];
105+
const monthSlug = child.snapshot.params['month'];
106+
if (!yearStr || !monthSlug) return;
107+
108+
// Find which Gregorian year contains this Islamic month
109+
for (const ye of this.yearEntries()) {
110+
const months = this.getMonthsForYear(ye.gregorianYear);
111+
const match = months.find(
112+
(m) =>
113+
`${m.year}AH` === yearStr && m.routeSlug.toLowerCase() === monthSlug.toLowerCase(),
114+
);
115+
if (match) {
116+
this.activeGregorianYear.set(ye.gregorianYear);
117+
this.islamicMonths.set(months);
118+
return;
119+
}
120+
}
121+
}
122+
49123
closeLocationDialog() {
50124
this.locationDialogService.close();
51125
}

src/app/pages/month-detail.component.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,22 @@ export class MonthDetailComponent implements OnInit, OnDestroy {
6565
this.visibilityGrids.set([]);
6666
this.notFound.set(false);
6767

68-
const months = this.moonService.getUpcomingIslamicMonths(new Date(), 12);
69-
const entry = this.moonService.findMonthByRoute(yearStr, monthSlug, months);
68+
// Search across a wide range of years to support year navigation
69+
const currentYear = new Date().getFullYear();
70+
let entry: IslamicMonthEntry | undefined;
71+
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
72+
const months = this.moonService.getIslamicMonthsForGregorianYear(y);
73+
entry = this.moonService.findMonthByRoute(yearStr, monthSlug, months);
74+
if (entry) break;
75+
}
7076

7177
if (!entry) {
7278
this.notFound.set(true);
7379
this.computing.set(false);
7480
// Redirect after a short delay
81+
const fallbackMonths = this.moonService.getUpcomingIslamicMonths(new Date(), 12);
7582
setTimeout(() => {
76-
const fallback = this.moonService.getNearestMonthRoute(months);
83+
const fallback = this.moonService.getNearestMonthRoute(fallbackMonths);
7784
this.router.navigateByUrl(fallback);
7885
}, 1500);
7986
return;

src/app/services/islamic-month.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,16 @@ export class IslamicMonthService {
171171
return entries;
172172
}
173173

174+
/**
175+
* Generate Islamic months whose new moon falls in a specific Gregorian year.
176+
*/
177+
getIslamicMonthsForGregorianYear(year: number): IslamicMonthEntry[] {
178+
// Start from December of previous year to catch late-December new moons
179+
const fromDate = new Date(year - 1, 11, 1);
180+
const months = this.getUpcomingIslamicMonths(fromDate, 16);
181+
return months.filter((m) => m.newMoonDate.getFullYear() === year);
182+
}
183+
174184
/**
175185
* Convert Islamic month name to a URL-safe slug.
176186
*/

0 commit comments

Comments
 (0)