diff --git a/SVSim.EmulatedEntrypoint/Services/JstPeriod.cs b/SVSim.EmulatedEntrypoint/Services/JstPeriod.cs
new file mode 100644
index 0000000..572fadb
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Services/JstPeriod.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+
+namespace SVSim.EmulatedEntrypoint.Services;
+
+///
+/// JST-anchored period bucketing for ViewerEventCounters. Day/week/month boundaries are
+/// 02:00 JST (matching the real-game daily reset). Pure functions, no dependencies.
+///
+public static class JstPeriod
+{
+ private static readonly TimeSpan Jst = TimeSpan.FromHours(9);
+ private const string DayPrefix = "day:";
+ private const string WeekPrefix = "week:";
+ private const string MonthPrefix = "month:";
+ public const string AllTime = "all-time";
+
+ ///
+ /// Converts the given instant to a JST-anchored "effective date" by:
+ /// 1. Shifting to JST (+09:00)
+ /// 2. Subtracting 2 hours so anything before 02:00 JST belongs to the previous day
+ ///
+ private static DateTime EffectiveJstDate(DateTimeOffset utcOrAny)
+ {
+ var jst = utcOrAny.ToOffset(Jst);
+ return jst.AddHours(-2).Date;
+ }
+
+ public static string DayKey(DateTimeOffset when)
+ {
+ var d = EffectiveJstDate(when);
+ return $"{DayPrefix}{d:yyyy-MM-dd}";
+ }
+
+ public static string WeekKey(DateTimeOffset when)
+ {
+ var d = EffectiveJstDate(when);
+ var iso = ISOWeek.GetWeekOfYear(d);
+ var year = ISOWeek.GetYear(d);
+ return $"{WeekPrefix}{year:D4}-W{iso:D2}";
+ }
+
+ public static string MonthKey(DateTimeOffset when)
+ {
+ var d = EffectiveJstDate(when);
+ return $"{MonthPrefix}{d:yyyy-MM}";
+ }
+
+ /// Returns [day, week, month, all-time] keys for the given instant.
+ public static IReadOnlyList AllPeriods(DateTimeOffset when) => new[]
+ {
+ DayKey(when), WeekKey(when), MonthKey(when), AllTime,
+ };
+}
diff --git a/SVSim.UnitTests/Services/JstPeriodTests.cs b/SVSim.UnitTests/Services/JstPeriodTests.cs
new file mode 100644
index 0000000..f243673
--- /dev/null
+++ b/SVSim.UnitTests/Services/JstPeriodTests.cs
@@ -0,0 +1,52 @@
+namespace SVSim.UnitTests.Services;
+
+public class JstPeriodTests
+{
+ private static DateTimeOffset Jst(int y, int m, int d, int h, int min, int s) =>
+ new DateTimeOffset(y, m, d, h, min, s, TimeSpan.FromHours(9));
+
+ [Test]
+ public void DayKey_uses_jst_with_02_00_anchor()
+ {
+ // 01:59:59 JST on May 27 belongs to the May 26 "day".
+ Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 1, 59, 59)),
+ Is.EqualTo("day:2026-05-26"));
+
+ // 02:00:00 JST on May 27 belongs to the May 27 "day".
+ Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 2, 0, 0)),
+ Is.EqualTo("day:2026-05-27"));
+ }
+
+ [Test]
+ public void WeekKey_is_iso8601_monday_anchored()
+ {
+ // 2026-05-25 (Monday) 02:00 JST → ISO week 2026-W22.
+ Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 2, 0, 0)),
+ Is.EqualTo("week:2026-W22"));
+
+ // The previous Sunday's late evening still belongs to W21 (week reset is Monday 02:00 JST).
+ Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 1, 59, 59)),
+ Is.EqualTo("week:2026-W21"));
+ }
+
+ [Test]
+ public void MonthKey_uses_jst_day_anchor_for_month_rollover()
+ {
+ // 01:59 JST on June 1 still belongs to May (day-boundary is 02:00 JST).
+ Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 1, 59, 0)),
+ Is.EqualTo("month:2026-05"));
+
+ Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 2, 0, 0)),
+ Is.EqualTo("month:2026-06"));
+ }
+
+ [Test]
+ public void AllPeriods_returns_day_week_month_all_time()
+ {
+ var t = Jst(2026, 5, 27, 12, 0, 0);
+ var periods = SVSim.EmulatedEntrypoint.Services.JstPeriod.AllPeriods(t);
+ Assert.That(periods, Is.EquivalentTo(new[] {
+ "day:2026-05-27", "week:2026-W22", "month:2026-05", "all-time",
+ }));
+ }
+}